mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-04-13 10:06:36 +00:00
feat: add ability to revoke passkeys of users as admin (#1386)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jose-d <7630424+jose-d@users.noreply.github.com> Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -83,7 +83,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
|
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
|
||||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
|
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
|
||||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
||||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
|
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
// Add device information to the logs
|
// Add device information to the logs
|
||||||
for i, logsDto := range logsDtos {
|
for i, logsDto := range logsDtos {
|
||||||
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||||
|
logsDto.ActorUsername = logsDto.Data["actorUsername"]
|
||||||
logsDtos[i] = logsDto
|
logsDtos[i] = logsDto
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
|||||||
for i, logsDto := range logsDtos {
|
for i, logsDto := range logsDtos {
|
||||||
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||||
logsDto.Username = logs[i].User.Username
|
logsDto.Username = logs[i].User.Username
|
||||||
|
logsDto.ActorUsername = logsDto.Data["actorUsername"]
|
||||||
logsDtos[i] = logsDto
|
logsDtos[i] = logsDto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
|||||||
// @Summary User management controller
|
// @Summary User management controller
|
||||||
// @Description Initializes all user-related API endpoints
|
// @Description Initializes all user-related API endpoints
|
||||||
// @Tags Users
|
// @Tags Users
|
||||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
|
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
|
||||||
uc := UserController{
|
uc := UserController{
|
||||||
userService: userService,
|
userService: userService,
|
||||||
oneTimeAccessService: oneTimeAccessService,
|
oneTimeAccessService: oneTimeAccessService,
|
||||||
|
webAuthnService: webAuthnService,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +35,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
|
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
|
||||||
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
|
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
|
||||||
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
|
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
|
||||||
|
group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), uc.listUserWebauthnCredentialsHandler)
|
||||||
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
|
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
|
||||||
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
|
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
|
||||||
|
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), uc.deleteUserWebauthnCredentialHandler)
|
||||||
|
|
||||||
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
type UserController struct {
|
type UserController struct {
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
oneTimeAccessService *service.OneTimeAccessService
|
oneTimeAccessService *service.OneTimeAccessService
|
||||||
|
webAuthnService *service.WebAuthnService
|
||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +91,36 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, groupsDto)
|
c.JSON(http.StatusOK, groupsDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listUserWebauthnCredentialsHandler godoc
|
||||||
|
// @Summary List user passkeys
|
||||||
|
// @Description Retrieve all WebAuthn credentials for a specific user
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {array} dto.WebauthnCredentialDto
|
||||||
|
// @Router /api/users/{id}/webauthn-credentials [get]
|
||||||
|
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentialDtos []dto.WebauthnCredentialDto
|
||||||
|
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, credentialDtos)
|
||||||
|
}
|
||||||
|
|
||||||
// listUsersHandler godoc
|
// listUsersHandler godoc
|
||||||
// @Summary List users
|
// @Summary List users
|
||||||
// @Description Get a paginated list of users with optional search and sorting
|
// @Description Get a paginated list of users with optional search and sorting
|
||||||
@@ -181,6 +215,31 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteUserWebauthnCredentialHandler godoc
|
||||||
|
// @Summary Delete user passkey
|
||||||
|
// @Description Delete a specific WebAuthn credential for a user
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param credentialId path string true "Credential ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/users/{id}/webauthn-credentials/{credentialId} [delete]
|
||||||
|
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
|
||||||
|
err := uc.webAuthnService.DeleteCredential(
|
||||||
|
c.Request.Context(),
|
||||||
|
c.Param("id"),
|
||||||
|
c.Param("credentialId"),
|
||||||
|
c.ClientIP(),
|
||||||
|
c.Request.UserAgent(),
|
||||||
|
c.GetString("userID"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// createUserHandler godoc
|
// createUserHandler godoc
|
||||||
// @Summary Create user
|
// @Summary Create user
|
||||||
// @Description Create a new user
|
// @Description Create a new user
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
|||||||
clientIP := c.ClientIP()
|
clientIP := c.ClientIP()
|
||||||
userAgent := c.Request.UserAgent()
|
userAgent := c.Request.UserAgent()
|
||||||
|
|
||||||
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent)
|
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ type AuditLogDto struct {
|
|||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
UserID string `json:"userID"`
|
UserID string `json:"userID"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
ActorUsername string `json:"actorUsername"`
|
||||||
Data map[string]string `json:"data"`
|
Data map[string]string `json:"data"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,26 +293,40 @@ func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([
|
|||||||
return credentials, nil
|
return credentials, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string) error {
|
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string, actorUserID string) error {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
credential := &model.WebauthnCredential{}
|
credential := &model.WebauthnCredential{}
|
||||||
err := tx.
|
result := tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Clauses(clause.Returning{}).
|
Clauses(clause.Returning{}).
|
||||||
Delete(credential, "id = ? AND user_id = ?", credentialID, userID).
|
Delete(credential, "id = ? AND user_id = ?", credentialID, userID)
|
||||||
Error
|
if result.Error != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to delete record: %w", result.Error)
|
||||||
return fmt.Errorf("failed to delete record: %w", err)
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
|
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
|
||||||
|
if actorUserID != "" && actorUserID != userID {
|
||||||
|
var actor model.User
|
||||||
|
err := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
First(&actor, "id = ?", actorUserID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load actor user: %w", err)
|
||||||
|
}
|
||||||
|
auditLogData["actorUserID"] = actorUserID
|
||||||
|
auditLogData["actorUsername"] = actor.Username
|
||||||
|
}
|
||||||
s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
|
s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err := tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"account_details": "Podrobnosti účtu",
|
"account_details": "Podrobnosti účtu",
|
||||||
"passkeys": "Přístupové klíče",
|
"passkeys": "Přístupové klíče",
|
||||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupové klíče, které můžete použít pro ověření.",
|
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupové klíče, které můžete použít pro ověření.",
|
||||||
|
"manage_this_users_passkeys": "Přehled a správa přístupových klíčů tohoto uživatele.",
|
||||||
"add_passkey": "Přidat přístupový klíč",
|
"add_passkey": "Přidat přístupový klíč",
|
||||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Vytvořte jednorázový přihlašovací kód pro přihlášení z jiného zařízení bez přístupového klíče.",
|
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Vytvořte jednorázový přihlašovací kód pro přihlášení z jiného zařízení bez přístupového klíče.",
|
||||||
"create": "Vytvořit",
|
"create": "Vytvořit",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"ip_address": "IP Address",
|
"ip_address": "IP Address",
|
||||||
"device": "Device",
|
"device": "Device",
|
||||||
"client": "Client",
|
"client": "Client",
|
||||||
|
"actor": "Actor",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"account_details_updated_successfully": "Account details updated successfully",
|
"account_details_updated_successfully": "Account details updated successfully",
|
||||||
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
|
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
"account_details": "Account Details",
|
"account_details": "Account Details",
|
||||||
"passkeys": "Passkeys",
|
"passkeys": "Passkeys",
|
||||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
|
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
|
||||||
|
"manage_this_users_passkeys": "Manage this user's passkeys.",
|
||||||
"add_passkey": "Add Passkey",
|
"add_passkey": "Add Passkey",
|
||||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
|
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
@@ -521,5 +523,6 @@
|
|||||||
"mark_as_verified": "Mark as verified",
|
"mark_as_verified": "Mark as verified",
|
||||||
"email_verification_sent": "Verification email sent successfully.",
|
"email_verification_sent": "Verification email sent successfully.",
|
||||||
"emails_verified_by_default": "Emails verified by default",
|
"emails_verified_by_default": "Emails verified by default",
|
||||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
|
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
|
||||||
|
"user_has_no_passkeys_yet": "This user has no passkeys yet."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"ip_address": "Indirizzo IP",
|
"ip_address": "Indirizzo IP",
|
||||||
"device": "Dispositivo",
|
"device": "Dispositivo",
|
||||||
"client": "Client",
|
"client": "Client",
|
||||||
|
"actor": "Autore",
|
||||||
"unknown": "Sconosciuto",
|
"unknown": "Sconosciuto",
|
||||||
"account_details_updated_successfully": "Dettagli dell'account aggiornati con successo",
|
"account_details_updated_successfully": "Dettagli dell'account aggiornati con successo",
|
||||||
"profile_picture_updated_successfully": "Immagine del profilo aggiornata con successo. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
|
"profile_picture_updated_successfully": "Immagine del profilo aggiornata con successo. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
hidden: !isAdmin,
|
hidden: !isAdmin,
|
||||||
value: (item) => item.username ?? m.unknown()
|
value: (item) => item.username ?? m.unknown()
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: m.actor(),
|
||||||
|
key: 'actorUsername',
|
||||||
|
hidden: !isAdmin,
|
||||||
|
value: (item) => item.actorUsername ?? m.unknown()
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: m.event(),
|
label: m.event(),
|
||||||
column: 'event',
|
column: 'event',
|
||||||
|
|||||||
@@ -9,12 +9,14 @@
|
|||||||
icon,
|
icon,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
showRenameAction = true,
|
||||||
label,
|
label,
|
||||||
description
|
description
|
||||||
}: {
|
}: {
|
||||||
icon: typeof IconType;
|
icon: typeof IconType;
|
||||||
onRename: () => void;
|
onRename?: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
showRenameAction?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Item.Content>
|
</Item.Content>
|
||||||
<Item.Actions>
|
<Item.Actions>
|
||||||
|
{#if showRenameAction && onRename}
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
<Tooltip.Content>{m.rename()}</Tooltip.Content>
|
<Tooltip.Content>{m.rename()}</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||||
|
import type { Passkey } from '$lib/types/passkey.type';
|
||||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
import type { SignupToken } from '$lib/types/signup-token.type';
|
||||||
import type { UserGroup } from '$lib/types/user-group.type';
|
import type { UserGroup } from '$lib/types/user-group.type';
|
||||||
import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||||
@@ -33,6 +34,11 @@ export default class UserService extends APIService {
|
|||||||
return res.data as UserGroup[];
|
return res.data as UserGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
listUserPasskeys = async (userId: string) => {
|
||||||
|
const res = await this.api.get(`/users/${userId}/webauthn-credentials`);
|
||||||
|
return res.data as Passkey[];
|
||||||
|
};
|
||||||
|
|
||||||
update = async (id: string, user: UserCreate) => {
|
update = async (id: string, user: UserCreate) => {
|
||||||
const res = await this.api.put(`/users/${id}`, user);
|
const res = await this.api.put(`/users/${id}`, user);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
@@ -47,6 +53,10 @@ export default class UserService extends APIService {
|
|||||||
await this.api.delete(`/users/${id}`);
|
await this.api.delete(`/users/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
removeUserPasskey = async (userId: string, passkeyId: string) => {
|
||||||
|
await this.api.delete(`/users/${userId}/webauthn-credentials/${passkeyId}`);
|
||||||
|
};
|
||||||
|
|
||||||
updateProfilePicture = async (userId: string, image: File) => {
|
updateProfilePicture = async (userId: string, image: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', image!);
|
formData.append('file', image!);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AuditLog = {
|
|||||||
device: string;
|
device: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
actorUsername?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
data: any;
|
data: any;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,23 +5,27 @@
|
|||||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Item from '$lib/components/ui/item/index.js';
|
||||||
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
|
import type { Passkey } from '$lib/types/passkey.type';
|
||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft } from '@lucide/svelte';
|
import { KeyRound, LucideChevronLeft } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { backNavigate } from '../navigate-back-util';
|
import { backNavigate } from '../navigate-back-util';
|
||||||
import UserForm from '../user-form.svelte';
|
import UserForm from '../user-form.svelte';
|
||||||
|
import AdminPasskeyList from './admin-passkey-list.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let user = $state({
|
let user = $state({
|
||||||
...data.user,
|
...data.user,
|
||||||
userGroupIds: data.user.userGroups.map((g) => g.id)
|
userGroupIds: data.user.userGroups.map((g) => g.id)
|
||||||
});
|
});
|
||||||
|
let passkeys: Passkey[] = $state(data.passkeys);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const customClaimService = new CustomClaimService();
|
const customClaimService = new CustomClaimService();
|
||||||
@@ -128,6 +132,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
|
|
||||||
|
<Item.Group class="bg-card rounded-xl border p-4 shadow-sm">
|
||||||
|
<Item.Root class="border-none bg-transparent p-0">
|
||||||
|
<Item.Media class="text-primary/80">
|
||||||
|
<KeyRound class="size-5" />
|
||||||
|
</Item.Media>
|
||||||
|
<Item.Content class="min-w-52">
|
||||||
|
<Item.Title class="text-xl font-semibold">{m.passkeys()}</Item.Title>
|
||||||
|
<Item.Description>{passkeys.length > 0 ? m.manage_this_users_passkeys() : m.user_has_no_passkeys_yet()}</Item.Description>
|
||||||
|
</Item.Content>
|
||||||
|
</Item.Root>
|
||||||
|
{#if passkeys.length > 0}
|
||||||
|
<AdminPasskeyList userId={user.id} bind:passkeys />
|
||||||
|
{/if}
|
||||||
|
</Item.Group>
|
||||||
|
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
id="user-custom-claims"
|
id="user-custom-claims"
|
||||||
title={m.custom_claims()}
|
title={m.custom_claims()}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import type { PageLoad } from './$types';
|
|||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ params }) => {
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const user = await userService.get(params.id);
|
const [user, passkeys] = await Promise.all([
|
||||||
|
userService.get(params.id),
|
||||||
|
userService.listUserPasskeys(params.id)
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user
|
user,
|
||||||
|
passkeys
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
|
import PasskeyRow from '$lib/components/passkey-row.svelte';
|
||||||
|
import * as Item from '$lib/components/ui/item/index.js';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import type { Passkey } from '$lib/types/passkey.type';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { LucideKeyRound } from '@lucide/svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let {
|
||||||
|
userId,
|
||||||
|
passkeys = $bindable()
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
passkeys: Passkey[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
async function refreshPasskeys() {
|
||||||
|
passkeys = await userService.listUserPasskeys(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePasskey(passkey: Passkey) {
|
||||||
|
openConfirmDialog({
|
||||||
|
title: m.delete_passkey_name({ passkeyName: passkey.name }),
|
||||||
|
message: m.are_you_sure_you_want_to_delete_this_passkey(),
|
||||||
|
confirm: {
|
||||||
|
label: m.delete(),
|
||||||
|
destructive: true,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await userService.removeUserPasskey(userId, passkey.id);
|
||||||
|
await refreshPasskeys();
|
||||||
|
toast.success(m.passkey_deleted_successfully());
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Item.Group class="mt-3">
|
||||||
|
{#each [...passkeys].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) as passkey}
|
||||||
|
<PasskeyRow
|
||||||
|
label={passkey.name}
|
||||||
|
description={m.added_on() + ' ' + new Date(passkey.createdAt).toLocaleDateString()}
|
||||||
|
icon={LucideKeyRound}
|
||||||
|
showRenameAction={false}
|
||||||
|
onDelete={() => deletePasskey(passkey)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</Item.Group>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
import { userGroups, users } from '../data';
|
import { userGroups, users } from '../data';
|
||||||
|
import authUtil from '../utils/auth.util';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
test.beforeEach(async () => await cleanupBackend());
|
test.beforeEach(async () => await cleanupBackend());
|
||||||
@@ -101,7 +102,7 @@ test('Delete user', async ({ page }) => {
|
|||||||
.getByRole('button')
|
.getByRole('button')
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText('User deleted successfully');
|
await expect(page.locator('[data-type="success"]')).toHaveText('User deleted successfully');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -251,3 +252,45 @@ test('Update user group assignments', async ({ page }) => {
|
|||||||
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
|
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
|
||||||
).toHaveAttribute('data-state', 'unchecked');
|
).toHaveAttribute('data-state', 'unchecked');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Admin can view another user passkeys', async ({ page }) => {
|
||||||
|
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||||
|
|
||||||
|
await expect(page.getByText('Passkey 2')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Added on/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin can delete another user passkey and audit log is created', async ({ page }) => {
|
||||||
|
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||||
|
|
||||||
|
await page.locator('[data-slot="item"]').filter({ hasText: 'Passkey 2' }).getByLabel('Delete').click();
|
||||||
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
|
||||||
|
await expect(page.getByText('Passkey 2')).not.toBeVisible();
|
||||||
|
|
||||||
|
const auditLogResponse = await page.request.get('/api/audit-logs/all');
|
||||||
|
expect(auditLogResponse.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const auditLogs = await auditLogResponse.json();
|
||||||
|
expect(
|
||||||
|
auditLogs.data.some(
|
||||||
|
(log: { event: string; userID: string; data?: { passkeyName?: string } }) =>
|
||||||
|
log.event === 'PASSKEY_REMOVED' &&
|
||||||
|
log.userID === users.craig.id &&
|
||||||
|
log.data?.passkeyName === 'Passkey 2'
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Non-admin cannot use admin passkey endpoints', async ({ page }) => {
|
||||||
|
await authUtil.changeUser(page, 'craig');
|
||||||
|
|
||||||
|
const listResponse = await page.request.get(`/api/users/${users.tim.id}/webauthn-credentials`);
|
||||||
|
expect(listResponse.status()).toBe(403);
|
||||||
|
|
||||||
|
const deleteResponse = await page.request.delete(
|
||||||
|
`/api/users/${users.tim.id}/webauthn-credentials/test-passkey-id`
|
||||||
|
);
|
||||||
|
expect(deleteResponse.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user