support unlinking external authentication

This commit is contained in:
MaysWind
2025-10-25 02:45:40 +08:00
parent 7b49a9f142
commit ce752c992c
26 changed files with 707 additions and 39 deletions
+4
View File
@@ -336,6 +336,10 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
}
// External Authentications
apiV1Route.GET("/users/external_auth/list.json", bindApi(api.UserExternalAuths.ExternalAuthListHanlder))
apiV1Route.POST("/users/external_auth/unlink.json", bindApi(api.UserExternalAuths.UnlinkExternalAuthHandler))
// Application Cloud Settings
apiV1Route.GET("/users/settings/cloud/get.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsGetHandler))
apiV1Route.POST("/users/settings/cloud/update.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsUpdateHandler))
+104
View File
@@ -0,0 +1,104 @@
package api
import (
"sort"
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
)
// UserExternalAuthsApi represents user external auth api
type UserExternalAuthsApi struct {
users *services.UserService
userExternalAuths *services.UserExternalAuthService
}
// Initialize a user external auth api singleton instance
var (
UserExternalAuths = &UserExternalAuthsApi{
users: services.Users,
userExternalAuths: services.UserExternalAuths,
}
)
// ExternalAuthListHanlder returns external authentications list of current user
func (a *UserExternalAuthsApi) ExternalAuthListHanlder(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
userExternalAuths, err := a.userExternalAuths.GetUserAllExternalAuthsByUid(c, uid)
if err != nil {
log.Errorf(c, "[user_external_auths.ExternalAuthListHanlder] failed to get all external authentications for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
userExternalAuthResps := make(models.UserExternalAuthInfoResponsesSlice, 0, len(userExternalAuths)+1)
currentExternalAuthType := oauth2.GetExternalUserAuthType()
hasCurrentExternalAuth := false
for i := 0; i < len(userExternalAuths); i++ {
userExternalAuth := userExternalAuths[i]
if userExternalAuth.ExternalAuthType == currentExternalAuthType {
hasCurrentExternalAuth = true
}
userExternalAuthResps = append(userExternalAuthResps, userExternalAuth.ToUserExternalAuthInfoResponse())
}
if !hasCurrentExternalAuth {
userExternalAuthResps = append(userExternalAuthResps, &models.UserExternalAuthInfoResponse{
ExternalAuthCategory: currentExternalAuthType.GetCategory(),
ExternalAuthType: currentExternalAuthType,
Linked: false,
})
}
sort.Sort(userExternalAuthResps)
return userExternalAuthResps, nil
}
// UnlinkExternalAuthHandler unlinks external authentication for current user
func (a *UserExternalAuthsApi) UnlinkExternalAuthHandler(c *core.WebContext) (any, *errs.Error) {
var externalAuthLinkReq models.UserExternalAuthUnlinkRequest
err := c.ShouldBindJSON(&externalAuthLinkReq)
if err != nil {
log.Warnf(c, "[user_external_auths.UnlinkExternalAuthHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[user_external_auths.UnlinkExternalAuthHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(externalAuthLinkReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
externalAuthType := core.UserExternalAuthType(externalAuthLinkReq.ExternalAuthType)
if !externalAuthType.IsValid() {
return nil, errs.ErrUserExternalAuthNotFound
}
err = a.userExternalAuths.DeleteUserExternalAuth(c, uid, externalAuthType)
if err != nil {
log.Errorf(c, "[user_external_auths.UnlinkExternalAuthHandler] failed to unlink external authentication \"%s\" for user \"uid:%d\", because %s", externalAuthLinkReq.ExternalAuthType, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
+11 -4
View File
@@ -1,5 +1,7 @@
package core
const USER_EXTERNAL_AUTH_TYPE_CATEOGRY_OAUTH2 = "oauth2"
// UserExternalAuthType represents the type of user external authentication
type UserExternalAuthType string
@@ -11,14 +13,19 @@ const (
USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB UserExternalAuthType = "github"
)
// IsValid checks if the UserExternalAuthType is valid
func (t UserExternalAuthType) IsValid() bool {
// GetCategory returns the category of the UserExternalAuthType
func (t UserExternalAuthType) GetCategory() string {
switch t {
case USER_EXTERNAL_AUTH_TYPE_OAUTH2_OIDC,
USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD,
USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITEA,
USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB:
return true
return USER_EXTERNAL_AUTH_TYPE_CATEOGRY_OAUTH2
}
return false
return ""
}
// IsValid checks if the UserExternalAuthType is valid
func (t UserExternalAuthType) IsValid() bool {
return t.GetCategory() != ""
}
+1
View File
@@ -8,4 +8,5 @@ import (
var (
ErrUserExternalAuthNotFound = NewNormalError(NormalSubcategoryUserExternalAuth, 0, http.StatusBadRequest, "user external auth is not found")
ErrUserExternalAuthAlreadyExists = NewNormalError(NormalSubcategoryUserExternalAuth, 1, http.StatusBadRequest, "user external auth already exists")
ErrUserExternalAuthTypeInvalid = NewNormalError(NormalSubcategoryUserExternalAuth, 2, http.StatusBadRequest, "user external auth type invalid")
)
+50 -3
View File
@@ -11,7 +11,54 @@ type UserExternalAuth struct {
CreatedUnixTime int64
}
// UserExternalAuthRevokeRequest represents all parameters of user external auth revoke request
type UserExternalAuthRevokeRequest struct {
ExternalAuthType core.UserExternalAuthType `json:"externalAuthType" binding:"required,notBlank"`
// UserExternalAuthUnlinkRequest represents all parameters of user external auth unlink request
type UserExternalAuthUnlinkRequest struct {
ExternalAuthType string `json:"externalAuthType" binding:"required,notBlank"`
Password string `json:"password" binding:"required,min=6,max=128"`
}
// UserExternalAuthInfoResponse represents a view-object of user external auth
type UserExternalAuthInfoResponse struct {
ExternalAuthCategory string `json:"externalAuthCategory"`
ExternalAuthType core.UserExternalAuthType `json:"externalAuthType"`
Linked bool `json:"linked"`
ExternalUsername string `json:"externalUsername,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
}
// ToUserExternalAuthInfoResponse returns a view-object according to database model
func (a *UserExternalAuth) ToUserExternalAuthInfoResponse() *UserExternalAuthInfoResponse {
return &UserExternalAuthInfoResponse{
ExternalAuthCategory: a.ExternalAuthType.GetCategory(),
ExternalAuthType: a.ExternalAuthType,
Linked: true,
ExternalUsername: a.ExternalUsername,
CreatedAt: a.CreatedUnixTime,
}
}
// UserExternalAuthInfoResponsesSlice represents the slice data structure of UserExternalAuthInfoResponse
type UserExternalAuthInfoResponsesSlice []*UserExternalAuthInfoResponse
// Len returns the count of items
func (a UserExternalAuthInfoResponsesSlice) Len() int {
return len(a)
}
// Swap swaps two items
func (a UserExternalAuthInfoResponsesSlice) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
// Less reports whether the first item is less than the second one
func (a UserExternalAuthInfoResponsesSlice) Less(i, j int) bool {
if a[i].Linked && !a[j].Linked {
return true
} else if !a[i].Linked && a[j].Linked {
return false
} else if !a[i].Linked && !a[j].Linked {
return a[i].ExternalAuthType < a[j].ExternalAuthType
}
return a[i].CreatedAt > a[j].CreatedAt
}
+41
View File
@@ -0,0 +1,41 @@
package models
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
func TestUserExternalAuthInfoResponsesSliceLess(t *testing.T) {
var userExternalAuthInfoResponsesSlice UserExternalAuthInfoResponsesSlice
userExternalAuthInfoResponsesSlice = append(userExternalAuthInfoResponsesSlice, &UserExternalAuthInfoResponse{
ExternalAuthType: core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_OIDC,
Linked: true,
ExternalUsername: "test",
CreatedAt: int64(1),
})
userExternalAuthInfoResponsesSlice = append(userExternalAuthInfoResponsesSlice, &UserExternalAuthInfoResponse{
ExternalAuthType: core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD,
Linked: false,
})
userExternalAuthInfoResponsesSlice = append(userExternalAuthInfoResponsesSlice, &UserExternalAuthInfoResponse{
ExternalAuthType: core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITEA,
Linked: false,
})
userExternalAuthInfoResponsesSlice = append(userExternalAuthInfoResponsesSlice, &UserExternalAuthInfoResponse{
ExternalAuthType: core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB,
Linked: true,
ExternalUsername: "test4",
CreatedAt: int64(2),
})
sort.Sort(userExternalAuthInfoResponsesSlice)
assert.Equal(t, core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB, userExternalAuthInfoResponsesSlice[0].ExternalAuthType)
assert.Equal(t, core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_OIDC, userExternalAuthInfoResponsesSlice[1].ExternalAuthType)
assert.Equal(t, core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITEA, userExternalAuthInfoResponsesSlice[2].ExternalAuthType)
assert.Equal(t, core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD, userExternalAuthInfoResponsesSlice[3].ExternalAuthType)
}
+10
View File
@@ -135,6 +135,10 @@ import type {
UserProfileUpdateRequest,
UserProfileUpdateResponse
} from '@/models/user.ts';
import type {
UserExternalAuthUnlinkRequest,
UserExternalAuthInfoResponse
} from '@/models/user_external_auth.ts';
import type {
OAuth2CallbackLoginRequest
} from '@/models/oauth2.ts';
@@ -323,6 +327,12 @@ export default {
});
});
},
getExternalAuths: (): ApiResponsePromise<UserExternalAuthInfoResponse[]> => {
return axios.get<ApiResponse<UserExternalAuthInfoResponse[]>>('v1/users/external_auth/list.json');
},
unlinkExternalAuth: (req: UserExternalAuthUnlinkRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/users/external_auth/unlink.json', req);
},
getTokens: (): ApiResponsePromise<TokenInfoResponse[]> => {
return axios.get<ApiResponse<TokenInfoResponse[]>>('v1/tokens/list.json');
},
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Alle Benutzerdaten wurden gelöscht",
"Unable to clear user data": "Benutzerdaten können nicht gelöscht werden",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Geräte & Sitzungen",
"Device Info": "Geräteinformationen",
"Last Activity Time": "Letzte Aktivitätszeit",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "All user data has been cleared",
"Unable to clear user data": "Unable to clear user data",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Device & Sessions",
"Device Info": "Device Info",
"Last Activity Time": "Last Activity Time",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Todos los datos del usuario han sido borrados.",
"Unable to clear user data": "No se pueden borrar los datos del usuario",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Dispositivo y sesiones",
"Device Info": "Información del dispositivo",
"Last Activity Time": "Hora de la última actividad",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "Aucune information de transaction détectée",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "Toutes les transactions ont été effacées",
"All user data has been cleared": "Toutes les données utilisateur ont été effacées",
"Unable to clear user data": "Impossible d'effacer les données utilisateur",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Appareils et sessions",
"Device Info": "Informations sur l'appareil",
"Last Activity Time": "Heure de dernière activité",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Tutti i dati utente sono stati cancellati",
"Unable to clear user data": "Impossibile cancellare i dati utente",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Dispositivo e sessioni",
"Device Info": "Info dispositivo",
"Last Activity Time": "Ora ultima attività",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "ユーザーデータがすべてクリアされました",
"Unable to clear user data": "ユーザーデータをクリアできません",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "デバイスとセッション",
"Device Info": "デバイス情報",
"Last Activity Time": "最後のアクティビティ時間",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "거래 정보가 감지되지 않았습니다.",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "모든 거래가 지워졌습니다.",
"All user data has been cleared": "모든 사용자 데이터가 지워졌습니다.",
"Unable to clear user data": "사용자 데이터를 지울 수 없습니다.",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "장치 및 세션",
"Device Info": "장치 정보",
"Last Activity Time": "마지막 활동 시간",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Alle gebruikersgegevens zijn gewist",
"Unable to clear user data": "Kan gebruikersgegevens niet wissen",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Apparaten & Sessies",
"Device Info": "Apparaatgegevens",
"Last Activity Time": "Laatste activiteit",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Todos os dados de usuário foram apagados",
"Unable to clear user data": "Não foi possível limpar os dados de usuário",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Dispositivo e Sessões",
"Device Info": "Informações do Dispositivo",
"Last Activity Time": "Última Hora de Atividade",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Все данные пользователя были очищены",
"Unable to clear user data": "Не удалось очистить данные пользователя",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Устройства и сессии",
"Device Info": "Информация об устройстве",
"Last Activity Time": "Время последней активности",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "ไม่พบข้อมูลธุรกรรม",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "รายการทั้งหมดถูกลบเรียบร้อยแล้ว",
"All user data has been cleared": "ข้อมูลผู้ใช้ทั้งหมดถูกลบเรียบร้อยแล้ว",
"Unable to clear user data": "ไม่สามารถลบข้อมูลผู้ใช้ได้",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "อุปกรณ์ & เซสชัน",
"Device Info": "ข้อมูลอุปกรณ์",
"Last Activity Time": "เวลาทำกิจกรรมล่าสุด",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Усі дані користувача очищено",
"Unable to clear user data": "Не вдалося очистити дані",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Пристрої та сесії",
"Device Info": "Інформація про пристрій",
"Last Activity Time": "Час останньої активності",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "No transaction information detected",
"user external auth is not found": "User external authentication data not found",
"user external auth already exists": "User external authentication data already exists, please unlink it first",
"user external auth type invalid": "User external authentication type is invalid",
"oauth2 not enabled": "OAuth 2.0 is not enabled",
"oauth2 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
"invalid oauth2 login request": "Invalid OAuth 2.0 login request",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "All transactions has been cleared",
"All user data has been cleared": "Tất cả dữ liệu người dùng đã bị xóa",
"Unable to clear user data": "Không thể xóa dữ liệu người dùng",
"Third-Party Logins": "Third-Party Logins",
"Linked Time": "Linked Time",
"Unlink": "Unlink",
"Are you sure you want to unlink this login method?": "Are you sure you want to unlink this login method?",
"Unable to retrieve third-party logins list": "Unable to retrieve third-party logins list",
"Third-party logins list is up to date": "Third-party logins list is up to date",
"Third-party logins list has been updated": "Third-party logins list has been updated",
"Unable to unlink third-party login": "Unable to unlink third-party login",
"Device & Sessions": "Thiết bị & Phiên",
"Device Info": "Thông tin thiết bị",
"Last Activity Time": "Thời gian hoạt động gần nhất",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "没有检测到交易信息",
"user external auth is not found": "找不到用户外部认证数据",
"user external auth already exists": "用户外部认证数据已存在,请先解绑",
"user external auth type invalid": "用户外部认证类型无效",
"oauth2 not enabled": "OAuth 2.0 没有启用",
"oauth2 auto registration not enabled": "OAuth 2.0 自动注册没有启用",
"invalid oauth2 login request": "无效的 OAuth 2.0 登录请求",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "所有交易已经清空",
"All user data has been cleared": "用户所有数据已经清空",
"Unable to clear user data": "无法清除用户数据",
"Third-Party Logins": "第三方登录",
"Linked Time": "关联时间",
"Unlink": "取消关联",
"Are you sure you want to unlink this login method?": "您确定要取消关联该登录方式?",
"Unable to retrieve third-party logins list": "无法获取第三方登录列表",
"Third-party logins list is up to date": "第三方登录列表已是最新",
"Third-party logins list has been updated": "关联账户列表已更新",
"Unable to unlink third-party login": "无法取消关联第三方登录",
"Device & Sessions": "设备和会话",
"Device Info": "设备信息",
"Last Activity Time": "最后活跃时间",
+9
View File
@@ -1245,6 +1245,7 @@
"no transaction information detected": "沒有檢測到交易資訊",
"user external auth is not found": "找不到使用者外部驗證資料",
"user external auth already exists": "使用者外部驗證資料已存在,請先解除連結",
"user external auth type invalid": "使用者外部驗證類型無效",
"oauth2 not enabled": "OAuth 2.0 未啟用",
"oauth2 auto registration not enabled": "OAuth 2.0 自動註冊未啟用",
"invalid oauth2 login request": "無效的 OAuth 2.0 登入請求",
@@ -2144,6 +2145,14 @@
"All transactions has been cleared": "所有交易已經清空",
"All user data has been cleared": "使用者所有資料已經清空",
"Unable to clear user data": "無法清除使用者資料",
"Third-Party Logins": "第三方登入",
"Linked Time": "連結時間",
"Unlink": "取消連結",
"Are you sure you want to unlink this login method?": "您確定要取消連結這個登入方式?",
"Unable to retrieve third-party logins list": "無法取得第三方登入清單",
"Third-party logins list is up to date": "第三方登入清單已是最新",
"Third-party logins list has been updated": "連結的帳戶清單已更新",
"Unable to unlink third-party login": "無法取消連結第三方登入",
"Device & Sessions": "裝置和會話",
"Device Info": "裝置資訊",
"Last Activity Time": "最後活動時間",
+12
View File
@@ -0,0 +1,12 @@
export interface UserExternalAuthUnlinkRequest {
readonly externalAuthType: string;
readonly password: string;
}
export interface UserExternalAuthInfoResponse {
readonly externalAuthCategory: string;
readonly externalAuthType: string;
readonly linked: boolean;
readonly externalUsername?: string;
readonly createdAt?: number;
}
+64
View File
@@ -0,0 +1,64 @@
import { defineStore } from 'pinia';
import type {UserExternalAuthInfoResponse, UserExternalAuthUnlinkRequest} from '@/models/user_external_auth.ts';
import logger from '@/lib/logger.ts';
import services from '@/lib/services.ts';
export const useUserExternalAuthStore = defineStore('userExternalAUth', () => {
function getExternalAuths(): Promise<UserExternalAuthInfoResponse[]> {
return new Promise((resolve, reject) => {
services.getExternalAuths().then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to retrieve third-party logins list' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to load third-party logins list', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to retrieve third-party logins list' });
} else {
reject(error);
}
});
});
}
function unlinkExternalAuth(req: UserExternalAuthUnlinkRequest): Promise<boolean> {
return new Promise((resolve, reject) => {
services.unlinkExternalAuth(req).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to unlink third-party login' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to revoke token', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to unlink third-party login' });
} else {
reject(error);
}
});
});
}
return {
// functions
getExternalAuths,
unlinkExternalAuth
};
});
@@ -0,0 +1,108 @@
<template>
<v-dialog width="640" :persistent="true" v-model="showState">
<v-card class="pa-2 pa-sm-4 pa-md-4">
<template #title>
<div class="d-flex align-center justify-center">
<h4 class="text-h4 text-error text-wrap">{{ tt('Are you sure you want to unlink this login method?') }}</h4>
</div>
</template>
<v-card-text class="mb-md-4 w-100 d-flex justify-center">
<div class="w-100">
<v-text-field
autocomplete="current-password"
type="password"
variant="underlined"
color="error"
:disabled="unlinking"
:placeholder="tt('Current Password')"
v-model="currentPassword"
/>
</div>
</v-card-text>
<v-card-text class="overflow-y-visible">
<div class="w-100 d-flex justify-center gap-4">
<v-btn color="error" :disabled="!currentPassword || unlinking" @click="confirm">
{{ tt('Confirm') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="unlinking"></v-progress-circular>
</v-btn>
<v-btn color="secondary" variant="tonal" :disabled="unlinking" @click="cancel">
{{ tt('Cancel') }}
</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import { ref, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useUserExternalAuthStore } from '@/stores/userExternalAuth.ts';
type SnackBarType = InstanceType<typeof SnackBar>;
const { tt } = useI18n();
const userExternalAuthStore = useUserExternalAuthStore();
let resolveFunc: (() => void) | null = null;
let rejectFunc: ((reason?: unknown) => void) | null = null;
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const showState = ref<boolean>(false);
const unlinking = ref<boolean>(false);
const currentPassword = ref<string>('');
const currentExternalAuthType = ref<string | undefined>(undefined);
function open(externalAuthType: string): Promise<void> {
showState.value = true;
unlinking.value = false;
currentPassword.value = '';
currentExternalAuthType.value = externalAuthType;
return new Promise((resolve, reject) => {
resolveFunc = resolve;
rejectFunc = reject;
});
}
function confirm(): void {
if (!currentExternalAuthType.value || !currentPassword.value) {
return;
}
unlinking.value = true;
userExternalAuthStore.unlinkExternalAuth({
externalAuthType: currentExternalAuthType.value,
password: currentPassword.value
}).then(() => {
unlinking.value = false;
resolveFunc?.();
showState.value = false;
}).catch(error => {
unlinking.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function cancel(): void {
rejectFunc?.();
showState.value = false;
}
defineExpose({
open
});
</script>
@@ -63,6 +63,61 @@
</v-card>
</v-col>
<v-col cols="12">
<v-card :class="{ 'disabled': loadingExternalAuth }">
<template #title>
<div class="d-flex align-center">
<span>{{ tt('Third-Party Logins') }}</span>
<v-btn density="compact" color="default" variant="text" size="24"
class="ms-2" :icon="true" :loading="loadingExternalAuth" @click="reloadExternalAuth(false)">
<template #loader>
<v-progress-circular indeterminate size="20"/>
</template>
<v-icon :icon="mdiRefresh" size="24" />
<v-tooltip activator="parent">{{ tt('Refresh') }}</v-tooltip>
</v-btn>
</div>
</template>
<v-table class="table-striped text-no-wrap" :hover="!loadingExternalAuth">
<thead>
<tr>
<th>{{ tt('Type') }}</th>
<th>{{ tt('Username') }}</th>
<th>{{ tt('Linked Time') }}</th>
<th class="text-right">{{ tt('Operation') }}</th>
</tr>
</thead>
<tbody>
<tr :key="itemIdx"
v-for="itemIdx in (loadingExternalAuth && (!externalAuths || externalAuths.length < 1) ? [ 1 ] : [])">
<td class="px-0" colspan="4">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</td>
</tr>
<tr :key="thirdPartyLogin.externalAuthType"
v-for="thirdPartyLogin in thirdPartyLogins">
<td class="text-sm">
<v-icon start :icon="thirdPartyLogin.icon"/>
{{ thirdPartyLogin.displayName }}
</td>
<td class="text-sm">{{ thirdPartyLogin.externalUsername }}</td>
<td class="text-sm">{{ thirdPartyLogin.createdAt }}</td>
<td class="text-sm text-right">
<v-btn density="comfortable" color="error" variant="tonal"
:disabled="loadingExternalAuth"
@click="unlinkExternalAuth(thirdPartyLogin)"
v-if="thirdPartyLogin.linked">
{{ tt('Unlink') }}
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-col>
<v-col cols="12">
<v-card :class="{ 'disabled': loadingSession }">
<template #title>
@@ -126,6 +181,7 @@
</v-col>
</v-row>
<unlink-third-party-login-dialog ref="unlinkThirdPartyLoginDialog" />
<user-generate-m-c-p-token-dialog ref="generateMCPTokenDialog" />
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
@@ -133,6 +189,7 @@
<script setup lang="ts">
import { VTextField } from 'vuetify/components/VTextField';
import UnlinkThirdPartyLoginDialog from '@/views/desktop/user/settings/dialogs/UnlinkThirdPartyLoginDialog.vue';
import UserGenerateMCPTokenDialog from '@/views/desktop/user/settings/dialogs/UserGenerateMCPTokenDialog.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
@@ -143,17 +200,21 @@ import { useI18n } from '@/locales/helpers.ts';
import { useRootStore } from '@/stores/index.ts';
import { useSettingsStore } from '@/stores/setting.ts';
import { useUserExternalAuthStore } from '@/stores/userExternalAuth.ts';
import { useTokensStore } from '@/stores/token.ts';
import { itemAndIndex, reversedItemAndIndex } from '@/core/base.ts';
import { type UserExternalAuthInfoResponse } from '@/models/user_external_auth.ts';
import { type TokenInfoResponse, SessionInfo } from '@/models/token.ts';
import { isEquals } from '@/lib/common.ts';
import { parseSessionInfo } from '@/lib/session.ts';
import { isMCPServerEnabled } from '@/lib/server_settings.ts';
import { getOIDCCustomDisplayNames, isMCPServerEnabled } from '@/lib/server_settings.ts';
import {
mdiRefresh,
mdiLinkVariant,
mdiGithub,
mdiCellphone,
mdiTablet,
mdiWatch,
@@ -163,40 +224,111 @@ import {
mdiDevices
} from '@mdi/js';
class DesktopPageLinkedThirdPartyLogin {
public readonly externalAuthType: string;
public readonly icon: string;
public readonly displayName: string;
public readonly linked: boolean;
public readonly externalUsername: string;
public readonly createdAt: string;
public constructor(externalAuthInfoResponse: UserExternalAuthInfoResponse) {
this.externalAuthType = externalAuthInfoResponse.externalAuthType;
this.linked = externalAuthInfoResponse.linked;
this.externalUsername = externalAuthInfoResponse.externalUsername ? externalAuthInfoResponse.externalUsername : '-';
this.createdAt = externalAuthInfoResponse.createdAt ? formatUnixTimeToLongDateTime(externalAuthInfoResponse.createdAt) : '-';
if (externalAuthInfoResponse.externalAuthCategory === 'oauth2') {
this.displayName = getLocalizedOAuth2ProviderName(externalAuthInfoResponse.externalAuthType, getOIDCCustomDisplayNames());
if (externalAuthInfoResponse.externalAuthType === 'github') {
this.icon = mdiGithub;
} else {
this.icon = mdiLinkVariant;
}
} else {
this.displayName = externalAuthInfoResponse.externalAuthType;
this.icon = mdiLinkVariant;
}
}
}
class DesktopPageSessionInfo extends SessionInfo {
public readonly icon: string;
public readonly lastSeenDateTime: string;
public constructor(sessionInfo: SessionInfo) {
super(sessionInfo.tokenId, sessionInfo.isCurrent, sessionInfo.deviceType, sessionInfo.deviceInfo, sessionInfo.createdByCli, sessionInfo.lastSeen);
this.icon = getTokenIcon(sessionInfo.deviceType);
this.icon = this.getTokenIcon(sessionInfo.deviceType);
this.lastSeenDateTime = sessionInfo.lastSeen ? formatUnixTimeToLongDateTime(sessionInfo.lastSeen) : '-';
}
private getTokenIcon(deviceType: string): string {
if (deviceType === 'phone') {
return mdiCellphone;
} else if (deviceType === 'wearable') {
return mdiWatch;
} else if (deviceType === 'tablet') {
return mdiTablet;
} else if (deviceType === 'tv') {
return mdiTelevision;
} else if (deviceType === 'mcp') {
return mdiCreationOutline;
} else if (deviceType === 'cli') {
return mdiConsole;
} else {
return mdiDevices;
}
}
}
type UnlinkThirdPartyLoginDialogType = InstanceType<typeof UnlinkThirdPartyLoginDialog>;
type UserGenerateMCPTokenDialogType = InstanceType<typeof UserGenerateMCPTokenDialog>;
type ConfirmDialogType = InstanceType<typeof ConfirmDialog>;
type SnackBarType = InstanceType<typeof SnackBar>;
const { tt, formatUnixTimeToLongDateTime, setLanguage } = useI18n();
const {
tt,
formatUnixTimeToLongDateTime,
getLocalizedOAuth2ProviderName,
setLanguage
} = useI18n();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const userExternalAuthStore = useUserExternalAuthStore();
const tokensStore = useTokensStore();
const newPasswordInput = useTemplateRef<VTextField>('newPasswordInput');
const confirmPasswordInput = useTemplateRef<VTextField>('confirmPasswordInput');
const unlinkThirdPartyLoginDialog = useTemplateRef<UnlinkThirdPartyLoginDialogType>('unlinkThirdPartyLoginDialog');
const generateMCPTokenDialog = useTemplateRef<UserGenerateMCPTokenDialogType>('generateMCPTokenDialog');
const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const externalAuths = ref<UserExternalAuthInfoResponse[]>([]);
const tokens = ref<TokenInfoResponse[]>([]);
const currentPassword = ref<string>('');
const newPassword = ref<string>('');
const confirmPassword = ref<string>('');
const updatingPassword = ref<boolean>(false);
const loadingExternalAuth = ref<boolean>(true);
const loadingSession = ref<boolean>(true);
const thirdPartyLogins = computed<DesktopPageLinkedThirdPartyLogin[]>(() => {
const logins: DesktopPageLinkedThirdPartyLogin[] = [];
if (!externalAuths.value) {
return logins;
}
for (const externalAuth of externalAuths.value) {
logins.push(new DesktopPageLinkedThirdPartyLogin(externalAuth));
}
return logins;
});
const sessions = computed<DesktopPageSessionInfo[]>(() => {
const sessions: DesktopPageSessionInfo[] = [];
@@ -226,37 +358,11 @@ const inputProblemMessage = computed<string | null>(() => {
}
});
function getTokenIcon(deviceType: string): string {
if (deviceType === 'phone') {
return mdiCellphone;
} else if (deviceType === 'wearable') {
return mdiWatch;
} else if (deviceType === 'tablet') {
return mdiTablet;
} else if (deviceType === 'tv') {
return mdiTelevision;
} else if (deviceType === 'mcp') {
return mdiCreationOutline;
} else if (deviceType === 'cli') {
return mdiConsole;
} else {
return mdiDevices;
}
}
function init(): void {
loadingExternalAuth.value = true;
loadingSession.value = true;
tokensStore.getAllTokens().then(response => {
tokens.value = response;
loadingSession.value = false;
}).catch(error => {
loadingSession.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
reloadExternalAuth(true);
reloadSessions(true);
}
function updatePassword(): void {
@@ -296,6 +402,35 @@ function updatePassword(): void {
});
}
function reloadExternalAuth(silent?: boolean): void {
loadingExternalAuth.value = true;
userExternalAuthStore.getExternalAuths().then(response => {
if (!silent) {
if (isEquals(externalAuths.value, response)) {
snackbar.value?.showMessage('Third-party logins list is up to date');
} else {
snackbar.value?.showMessage('Third-party logins list has been updated');
}
}
externalAuths.value = response;
loadingExternalAuth.value = false;
}).catch(error => {
loadingExternalAuth.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function unlinkExternalAuth(thirdPartyLogin: DesktopPageLinkedThirdPartyLogin): void {
unlinkThirdPartyLoginDialog.value?.open(thirdPartyLogin.externalAuthType).then(() => {
reloadExternalAuth(true);
});
}
function generateMCPToken(): void {
generateMCPTokenDialog.value?.open().then(() => {
reloadSessions(true);