Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6774c401bf | ||
|
|
1f1fd90890 |
@@ -117,6 +117,8 @@ func main() {
|
|||||||
adminService := services.NewAdminService(
|
adminService := services.NewAdminService(
|
||||||
db.UserRepo,
|
db.UserRepo,
|
||||||
db.GroupRepo,
|
db.GroupRepo,
|
||||||
|
db.ProviderRepo,
|
||||||
|
db.LinkRepo,
|
||||||
db.SpaceRepo,
|
db.SpaceRepo,
|
||||||
db.MembershipRepo,
|
db.MembershipRepo,
|
||||||
db.NoteRepo,
|
db.NoteRepo,
|
||||||
@@ -255,10 +257,12 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET")
|
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET")
|
||||||
|
admin.HandleFunc("/users/{userId}", adminHandler.DeleteUser).Methods("DELETE")
|
||||||
admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
|
admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
|
||||||
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
|
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
|
||||||
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
|
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
|
||||||
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT")
|
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT")
|
||||||
|
admin.HandleFunc("/groups/{groupId}", adminHandler.DeleteGroup).Methods("DELETE")
|
||||||
admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET")
|
admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET")
|
||||||
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
|
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
|
||||||
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
|
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
|
||||||
@@ -270,6 +274,8 @@ func main() {
|
|||||||
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
||||||
// manage identity providers — admin-only
|
// manage identity providers — admin-only
|
||||||
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
||||||
|
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
|
||||||
|
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
|
||||||
|
|
||||||
// Serve static files (frontend) for all other routes
|
// Serve static files (frontend) for all other routes
|
||||||
// This must be after all API route handlers to allow API routes to take precedence
|
// This must be after all API route handlers to allow API routes to take precedence
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ type CreateAuthProviderRequest struct {
|
|||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAuthProviderRequest represents an OAuth/OIDC provider update request.
|
||||||
|
// ClientSecret may be empty to keep the existing secret.
|
||||||
|
type UpdateAuthProviderRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
AuthorizationURL string `json:"authorization_url"`
|
||||||
|
TokenURL string `json:"token_url"`
|
||||||
|
UserInfoURL string `json:"userinfo_url"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
IDTokenClaim string `json:"id_token_claim,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
// FeatureFlagsDTO represents app-wide feature flags in API responses.
|
// FeatureFlagsDTO represents app-wide feature flags in API responses.
|
||||||
type FeatureFlagsDTO struct {
|
type FeatureFlagsDTO struct {
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
type AdminService struct {
|
type AdminService struct {
|
||||||
userRepo repositories.UserRepository
|
userRepo repositories.UserRepository
|
||||||
groupRepo repositories.GroupRepository
|
groupRepo repositories.GroupRepository
|
||||||
|
providerRepo repositories.AuthProviderRepository
|
||||||
|
linkRepo repositories.UserProviderLinkRepository
|
||||||
spaceRepo repositories.SpaceRepository
|
spaceRepo repositories.SpaceRepository
|
||||||
membershipRepo repositories.MembershipRepository
|
membershipRepo repositories.MembershipRepository
|
||||||
noteRepo repositories.NoteRepository
|
noteRepo repositories.NoteRepository
|
||||||
@@ -30,6 +32,8 @@ type AdminService struct {
|
|||||||
func NewAdminService(
|
func NewAdminService(
|
||||||
userRepo repositories.UserRepository,
|
userRepo repositories.UserRepository,
|
||||||
groupRepo repositories.GroupRepository,
|
groupRepo repositories.GroupRepository,
|
||||||
|
providerRepo repositories.AuthProviderRepository,
|
||||||
|
linkRepo repositories.UserProviderLinkRepository,
|
||||||
spaceRepo repositories.SpaceRepository,
|
spaceRepo repositories.SpaceRepository,
|
||||||
membershipRepo repositories.MembershipRepository,
|
membershipRepo repositories.MembershipRepository,
|
||||||
noteRepo repositories.NoteRepository,
|
noteRepo repositories.NoteRepository,
|
||||||
@@ -41,6 +45,8 @@ func NewAdminService(
|
|||||||
return &AdminService{
|
return &AdminService{
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
groupRepo: groupRepo,
|
groupRepo: groupRepo,
|
||||||
|
providerRepo: providerRepo,
|
||||||
|
linkRepo: linkRepo,
|
||||||
spaceRepo: spaceRepo,
|
spaceRepo: spaceRepo,
|
||||||
membershipRepo: membershipRepo,
|
membershipRepo: membershipRepo,
|
||||||
noteRepo: noteRepo,
|
noteRepo: noteRepo,
|
||||||
@@ -51,6 +57,114 @@ func NewAdminService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUser deletes a user and related memberships/provider links.
|
||||||
|
func (s *AdminService) DeleteUser(ctx context.Context, currentUserID, targetUserID bson.ObjectID) error {
|
||||||
|
if currentUserID == targetUserID {
|
||||||
|
return errors.New("you cannot delete your own account")
|
||||||
|
}
|
||||||
|
|
||||||
|
spaces, err := s.spaceRepo.GetAllSpaces(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, space := range spaces {
|
||||||
|
if space.OwnerID == targetUserID {
|
||||||
|
return errors.New("cannot delete user that owns spaces; transfer or delete spaces first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberships, err := s.membershipRepo.GetUserMemberships(ctx, targetUserID)
|
||||||
|
if err == nil {
|
||||||
|
for _, membership := range memberships {
|
||||||
|
if err := s.membershipRepo.DeleteMembership(ctx, membership.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.linkRepo != nil {
|
||||||
|
links, err := s.linkRepo.GetUserLinks(ctx, targetUserID)
|
||||||
|
if err == nil {
|
||||||
|
for _, link := range links {
|
||||||
|
if err := s.linkRepo.DeleteLink(ctx, link.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.userRepo.DeleteUser(ctx, targetUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup deletes a non-system group and removes it from users.
|
||||||
|
func (s *AdminService) DeleteGroup(ctx context.Context, groupID bson.ObjectID) error {
|
||||||
|
group, err := s.groupRepo.GetGroupByID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group.IsSystem {
|
||||||
|
return errors.New("system groups cannot be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := s.userRepo.ListAllUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
filtered := make([]bson.ObjectID, 0, len(user.GroupIDs))
|
||||||
|
changed := false
|
||||||
|
for _, assignedGroupID := range user.GroupIDs {
|
||||||
|
if assignedGroupID == groupID {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, assignedGroupID)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user.GroupIDs = filtered
|
||||||
|
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupRepo.DeleteGroup(ctx, groupID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.refreshAllUserPermissions(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProvider deletes an auth provider and all user-provider links connected to it.
|
||||||
|
func (s *AdminService) DeleteProvider(ctx context.Context, providerID bson.ObjectID) error {
|
||||||
|
if s.providerRepo == nil {
|
||||||
|
return errors.New("provider repository unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.linkRepo != nil {
|
||||||
|
users, err := s.userRepo.ListAllUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
links, err := s.linkRepo.GetUserLinks(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
if link.ProviderID == providerID {
|
||||||
|
if err := s.linkRepo.DeleteLink(ctx, link.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.providerRepo.DeleteProvider(ctx, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
// ListUsers returns all users as admin DTOs
|
// ListUsers returns all users as admin DTOs
|
||||||
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
|
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
|
||||||
users, err := s.userRepo.ListAllUsers(ctx)
|
users, err := s.userRepo.ListAllUsers(ctx)
|
||||||
|
|||||||
@@ -319,6 +319,57 @@ func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthPro
|
|||||||
return dto.NewAuthProviderDTO(provider), nil
|
return dto.NewAuthProviderDTO(provider), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateProvider updates an existing OAuth/OIDC provider.
|
||||||
|
// If ClientSecret is empty, the existing encrypted secret is preserved.
|
||||||
|
func (s *AuthService) UpdateProvider(ctx context.Context, providerID bson.ObjectID, req *dto.UpdateAuthProviderRequest) (*dto.AuthProviderDTO, error) {
|
||||||
|
if s.providerRepo == nil || s.encryptor == nil {
|
||||||
|
return nil, errors.New("provider configuration unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.providerRepo.GetProviderByID(ctx, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
if providerType != "oidc" && providerType != "oauth2" {
|
||||||
|
return nil, errors.New("provider type must be oidc or oauth2")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
clientID := strings.TrimSpace(req.ClientID)
|
||||||
|
authorizationURL := strings.TrimSpace(req.AuthorizationURL)
|
||||||
|
tokenURL := strings.TrimSpace(req.TokenURL)
|
||||||
|
if name == "" || clientID == "" || authorizationURL == "" || tokenURL == "" {
|
||||||
|
return nil, errors.New("missing required provider fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Name = name
|
||||||
|
existing.Type = providerType
|
||||||
|
existing.ClientID = clientID
|
||||||
|
existing.AuthorizationURL = authorizationURL
|
||||||
|
existing.TokenURL = tokenURL
|
||||||
|
existing.UserInfoURL = strings.TrimSpace(req.UserInfoURL)
|
||||||
|
existing.Scopes = normalizeScopes(req.Scopes, providerType)
|
||||||
|
existing.IDTokenClaim = strings.TrimSpace(req.IDTokenClaim)
|
||||||
|
existing.IsActive = req.IsActive
|
||||||
|
|
||||||
|
clientSecret := strings.TrimSpace(req.ClientSecret)
|
||||||
|
if clientSecret != "" {
|
||||||
|
encrypted, err := s.encryptor.Encrypt(clientSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
existing.ClientSecret = encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.providerRepo.UpdateProvider(ctx, existing); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewAuthProviderDTO(existing), nil
|
||||||
|
}
|
||||||
|
|
||||||
// BuildProviderAuthorizationURL constructs a provider authorization URL.
|
// BuildProviderAuthorizationURL constructs a provider authorization URL.
|
||||||
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
|
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
|
||||||
flags, err := s.GetFeatureFlags(ctx)
|
flags, err := s.GetFeatureFlags(ctx)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
@@ -32,6 +33,33 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
|
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUser handles DELETE /admin/users/{userId}
|
||||||
|
func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetUserID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid user id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUserIDHex, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentUserID, err := bson.ObjectIDFromHex(currentUserIDHex)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.adminService.DeleteUser(r.Context(), currentUserID, targetUserID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateUserGroups handles PUT /admin/users/{userId}/groups
|
// UpdateUserGroups handles PUT /admin/users/{userId}/groups
|
||||||
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
|
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
|
||||||
@@ -66,6 +94,22 @@ func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request)
|
|||||||
json.NewEncoder(w).Encode(user)
|
json.NewEncoder(w).Encode(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteGroup handles DELETE /admin/groups/{groupId}
|
||||||
|
func (h *AdminHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID, err := bson.ObjectIDFromHex(mux.Vars(r)["groupId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid group id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.adminService.DeleteGroup(r.Context(), groupID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ListGroups handles GET /admin/groups
|
// ListGroups handles GET /admin/groups
|
||||||
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
groups, err := h.adminService.ListGroups(r.Context())
|
groups, err := h.adminService.ListGroups(r.Context())
|
||||||
@@ -292,3 +336,19 @@ func (h *AdminHandler) UpdateFeatureFlags(w http.ResponseWriter, r *http.Request
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(flags)
|
json.NewEncoder(w).Encode(flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteProvider handles DELETE /admin/auth/providers/{providerId}
|
||||||
|
func (h *AdminHandler) DeleteProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid provider id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.adminService.DeleteProvider(r.Context(), providerID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,6 +141,30 @@ func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(provider)
|
json.NewEncoder(w).Encode(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateProvider updates an existing OAuth/OIDC provider configuration.
|
||||||
|
func (h *AuthHandler) UpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateAuthProviderRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := h.authService.UpdateProvider(r.Context(), providerID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(provider)
|
||||||
|
}
|
||||||
|
|
||||||
// StartProviderLogin redirects the browser to the selected provider.
|
// StartProviderLogin redirects the browser to the selected provider.
|
||||||
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||||
|
|||||||
@@ -205,7 +205,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="currentUser && isAdminRoute" class="container py-4">
|
<div v-else-if="currentUser && isAdminRoute" class="admin-route-view">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -965,6 +965,16 @@ const logout = () => {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-route-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-navbar {
|
.app-navbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
125
frontend/src/components/AdminGroupModal.vue
Normal file
125
frontend/src/components/AdminGroupModal.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ mode === "create" ? "Create Group" : "Edit Group" }}</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Group name</label>
|
||||||
|
<input v-model="form.name" class="form-control" type="text" required :disabled="isSystemGroup" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input v-model="form.description" class="form-control" type="text" :disabled="isSystemGroup" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Permissions (one per line)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.permissionsText"
|
||||||
|
class="form-control permissions-textarea"
|
||||||
|
rows="10"
|
||||||
|
placeholder="space.create space.project_docs.category.create space.project_docs.*"
|
||||||
|
:disabled="isSystemGroup"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||||
|
<button v-if="!isSystemGroup" type="submit" class="btn btn-primary" :disabled="submitting">
|
||||||
|
{{ submitting ? "Saving..." : mode === "create" ? "Create Group" : "Save Changes" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: "create",
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isSystemGroup: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
submitting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "submit"]);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
permissionsText: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrateForm = () => {
|
||||||
|
form.value = {
|
||||||
|
name: props.group?.name || "",
|
||||||
|
description: props.group?.description || "",
|
||||||
|
permissionsText: (props.group?.permissions || []).join("\n"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => [props.mode, props.group], hydrateForm, { immediate: true });
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit("submit", {
|
||||||
|
name: form.value.name,
|
||||||
|
description: form.value.description,
|
||||||
|
permissionsText: form.value.permissionsText,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-modal {
|
||||||
|
z-index: 2000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-backdrop {
|
||||||
|
z-index: 1990;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissions-textarea {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.admin-modal {
|
||||||
|
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 0.75rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
frontend/src/components/AdminProviderModal.vue
Normal file
187
frontend/src/components/AdminProviderModal.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ mode === "create" ? "Add Identity Provider" : "Edit Identity Provider" }}</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Display Name <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Provider Type <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.type" class="form-select">
|
||||||
|
<option value="oidc">OIDC</option>
|
||||||
|
<option value="oauth2">OAuth2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Client ID <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.client_id" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">
|
||||||
|
Client Secret
|
||||||
|
<span v-if="mode === 'create'" class="text-danger">*</span>
|
||||||
|
<span v-else class="text-muted small">(leave blank to keep existing)</span>
|
||||||
|
</label>
|
||||||
|
<input v-model="form.client_secret" type="password" class="form-control" :required="mode === 'create'" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Authorization URL <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.authorization_url" type="url" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Token URL <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.token_url" type="url" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">UserInfo URL</label>
|
||||||
|
<input v-model="form.userinfo_url" type="url" class="form-control" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">ID Token Claim</label>
|
||||||
|
<input v-model="form.id_token_claim" type="text" class="form-control" placeholder="id_token" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Scopes</label>
|
||||||
|
<input v-model="form.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
|
||||||
|
<div class="form-text">Comma-separated list of OAuth scopes.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input id="provider-active" v-model="form.is_active" type="checkbox" class="form-check-input" />
|
||||||
|
<label for="provider-active" class="form-check-label">Provider is active</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||||
|
{{ submitting ? "Saving..." : mode === "create" ? "Add Provider" : "Save Changes" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: "create",
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
submitting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "submit"]);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: "",
|
||||||
|
type: "oidc",
|
||||||
|
client_id: "",
|
||||||
|
client_secret: "",
|
||||||
|
authorization_url: "",
|
||||||
|
token_url: "",
|
||||||
|
userinfo_url: "",
|
||||||
|
id_token_claim: "id_token",
|
||||||
|
scopes: "openid, profile, email",
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrateForm = () => {
|
||||||
|
if (props.mode === "edit" && props.provider) {
|
||||||
|
form.value = {
|
||||||
|
name: props.provider.name || "",
|
||||||
|
type: props.provider.type || "oidc",
|
||||||
|
client_id: props.provider.client_id || "",
|
||||||
|
client_secret: "",
|
||||||
|
authorization_url: props.provider.authorization_url || "",
|
||||||
|
token_url: props.provider.token_url || "",
|
||||||
|
userinfo_url: props.provider.userinfo_url || "",
|
||||||
|
id_token_claim: props.provider.id_token_claim || "id_token",
|
||||||
|
scopes: (props.provider.scopes || []).join(", "),
|
||||||
|
is_active: props.provider.is_active ?? true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
form.value = {
|
||||||
|
name: "",
|
||||||
|
type: "oidc",
|
||||||
|
client_id: "",
|
||||||
|
client_secret: "",
|
||||||
|
authorization_url: "",
|
||||||
|
token_url: "",
|
||||||
|
userinfo_url: "",
|
||||||
|
id_token_claim: "id_token",
|
||||||
|
scopes: "openid, profile, email",
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => [props.mode, props.provider], hydrateForm, { immediate: true });
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit("submit", {
|
||||||
|
name: form.value.name,
|
||||||
|
type: form.value.type,
|
||||||
|
client_id: form.value.client_id,
|
||||||
|
client_secret: form.value.client_secret,
|
||||||
|
authorization_url: form.value.authorization_url,
|
||||||
|
token_url: form.value.token_url,
|
||||||
|
userinfo_url: form.value.userinfo_url,
|
||||||
|
id_token_claim: form.value.id_token_claim,
|
||||||
|
scopes: form.value.scopes
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
is_active: form.value.is_active,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-modal {
|
||||||
|
z-index: 2000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-backdrop {
|
||||||
|
z-index: 1990;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.admin-modal {
|
||||||
|
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
|
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">Edit Space</h5>
|
<h5 class="modal-title">Edit Space</h5>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop fade show"></div>
|
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||||
</teleport>
|
</teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -252,3 +252,30 @@ const deleteSpace = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-modal {
|
||||||
|
z-index: 2000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-backdrop {
|
||||||
|
z-index: 1990;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.admin-modal {
|
||||||
|
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 0.75rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
111
frontend/src/components/AdminUserModal.vue
Normal file
111
frontend/src/components/AdminUserModal.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit User</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input class="form-control" :value="user?.username || ''" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input class="form-control" :value="user?.email || ''" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<input class="form-control" :value="user?.is_active ? 'Active' : 'Inactive'" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Groups</label>
|
||||||
|
<select v-model="groupIds" class="form-select" multiple>
|
||||||
|
<option v-for="group in groups" :key="group.id" :value="group.id">
|
||||||
|
{{ group.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||||
|
{{ submitting ? "Saving..." : "Save Changes" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
submitting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "submit"]);
|
||||||
|
|
||||||
|
const groupIds = ref([]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.user,
|
||||||
|
(user) => {
|
||||||
|
groupIds.value = [...(user?.group_ids || [])];
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit("submit", { group_ids: groupIds.value });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-modal {
|
||||||
|
z-index: 2000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-backdrop {
|
||||||
|
z-index: 1990;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.admin-modal {
|
||||||
|
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal .modal-dialog {
|
||||||
|
margin: 0.75rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,34 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin-page">
|
<div class="admin-page">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
|
<div class="admin-topbar d-flex justify-content-between align-items-center mb-0 gap-2">
|
||||||
|
<button class="btn btn-outline-secondary d-md-none" type="button" aria-label="Open admin navigation" @click="showMobileSidebar = true">
|
||||||
|
<i class="mdi mdi-menu" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div class="d-flex align-items-start gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-1">Admin Panel</h2>
|
<h2 class="mb-1">Admin Panel</h2>
|
||||||
<p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
|
<p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button>
|
<button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||||
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
|
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
|
||||||
|
|
||||||
<ul class="nav nav-tabs mb-3">
|
<div class="admin-shell">
|
||||||
<li class="nav-item">
|
<div v-if="showMobileSidebar" class="admin-sidebar-backdrop" @click="showMobileSidebar = false"></div>
|
||||||
<button class="nav-link" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link" :class="{ active: activeTab === 'groups' }" @click="activeTab = 'groups'">Groups</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link" :class="{ active: activeTab === 'spaces' }" @click="activeTab = 'spaces'">Spaces</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link" :class="{ active: activeTab === 'providers' }" @click="activeTab = 'providers'">Identity Providers</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link" :class="{ active: activeTab === 'featureFlags' }" @click="activeTab = 'featureFlags'">Feature Flags</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
|
<aside class="admin-sidebar" :class="{ open: showMobileSidebar }">
|
||||||
|
<div class="admin-sidebar-inner">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-2 py-1 d-md-none">
|
||||||
|
<h6 class="mb-0">Admin Sections</h6>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="showMobileSidebar = false"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav nav-pills flex-column gap-1 admin-nav">
|
||||||
|
<button v-for="tab in adminTabs" :key="tab.id" class="nav-link text-start" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="admin-content">
|
||||||
<section v-if="activeTab === 'users'" class="admin-section card border-0 shadow-sm">
|
<section v-if="activeTab === 'users'" class="admin-section card border-0 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
@@ -38,48 +44,39 @@
|
|||||||
|
|
||||||
<div v-if="loadingUsers" class="text-muted small">Loading users...</div>
|
<div v-if="loadingUsers" class="text-muted small">Loading users...</div>
|
||||||
<div v-else-if="users.length === 0" class="border rounded p-3 text-muted">No users found.</div>
|
<div v-else-if="users.length === 0" class="border rounded p-3 text-muted">No users found.</div>
|
||||||
<div v-else class="table-responsive">
|
<div v-else class="list-group users-list">
|
||||||
<table class="table table-sm table-hover align-middle mb-0">
|
<div v-for="u in users" :key="u.id" class="list-group-item user-row">
|
||||||
<thead class="table-light">
|
<div class="user-row-main">
|
||||||
<tr>
|
<div class="user-name-line">
|
||||||
<th>Username</th>
|
<span class="fw-semibold user-name">{{ u.username }}</span>
|
||||||
<th>Email</th>
|
|
||||||
<th>Groups</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Joined</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="u in users" :key="u.id">
|
|
||||||
<td>{{ u.username }}</td>
|
|
||||||
<td class="text-muted small">{{ u.email }}</td>
|
|
||||||
<td style="min-width: 260px">
|
|
||||||
<select
|
|
||||||
class="form-select form-select-sm"
|
|
||||||
multiple
|
|
||||||
:value="u.group_ids || []"
|
|
||||||
@change="
|
|
||||||
updateUserGroups(
|
|
||||||
u.id,
|
|
||||||
Array.from($event.target.selectedOptions).map((option) => option.value),
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<option v-for="group in groups" :key="group.id" :value="group.id">
|
|
||||||
{{ group.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<div class="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
<span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||||
{{ u.is_active ? "Active" : "Inactive" }}
|
{{ u.is_active ? "Active" : "Inactive" }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
<td class="text-muted small">{{ formatDate(u.created_at) }}</td>
|
|
||||||
</tr>
|
<div class="user-meta-grid">
|
||||||
</tbody>
|
<div class="user-meta-item">
|
||||||
</table>
|
<div class="user-meta-label">Email</div>
|
||||||
|
<div class="user-meta-value">{{ u.email }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-meta-item">
|
||||||
|
<div class="user-meta-label">Joined</div>
|
||||||
|
<div class="user-meta-value">{{ formatDate(u.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-meta-item user-meta-item-groups">
|
||||||
|
<div class="user-meta-label">Groups</div>
|
||||||
|
<div class="user-meta-value">{{ getUserGroupSummary(u) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-row-actions">
|
||||||
|
<div class="d-flex gap-2 user-actions-stack">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -106,7 +103,10 @@
|
|||||||
<div class="small text-muted">{{ group.description || "No description" }}</div>
|
<div class="small text-muted">{{ group.description || "No description" }}</div>
|
||||||
<div class="small text-muted">{{ (group.permissions || []).length }} permission{{ (group.permissions || []).length === 1 ? "" : "s" }}</div>
|
<div class="small text-muted">{{ (group.permissions || []).length }} permission{{ (group.permissions || []).length === 1 ? "" : "s" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,77 +140,26 @@
|
|||||||
|
|
||||||
<section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm">
|
<section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h5 class="mb-0">Configured Providers</h5>
|
<h5 class="mb-0">Identity Providers</h5>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingProviders" @click="loadProviders">Refresh</button>
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingProviders" @click="loadProviders">Refresh</button>
|
||||||
|
<button class="btn btn-sm btn-primary" @click="openCreateProviderModal"><i class="mdi mdi-plus me-1" aria-hidden="true"></i>Add Provider</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingProviders" class="text-muted small">Loading providers...</div>
|
<div v-if="loadingProviders" class="text-muted small">Loading providers...</div>
|
||||||
<div v-else-if="providers.length === 0" class="border rounded p-3 text-muted">No providers configured yet.</div>
|
<div v-else-if="providers.length === 0" class="border rounded p-3 text-muted">No providers configured yet.</div>
|
||||||
<div v-else class="list-group mb-3">
|
<div v-else class="list-group">
|
||||||
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div class="fw-semibold">{{ provider.name }}</div>
|
<span class="fw-semibold">{{ provider.name }}</span>
|
||||||
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
|
|
||||||
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
<div class="d-flex gap-2">
|
||||||
{{ provider.is_active ? "Active" : "Disabled" }}
|
<button class="btn btn-sm btn-outline-secondary" @click="openEditProviderModal(provider)">Edit</button>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="mb-2">Add Provider</h6>
|
|
||||||
<form class="row g-3" @submit.prevent="createProvider">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Display Name</label>
|
|
||||||
<input v-model="providerForm.name" type="text" class="form-control" required />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Provider Type</label>
|
|
||||||
<select v-model="providerForm.type" class="form-select">
|
|
||||||
<option value="oidc">OIDC</option>
|
|
||||||
<option value="oauth2">OAuth2</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Client ID</label>
|
|
||||||
<input v-model="providerForm.client_id" type="text" class="form-control" required />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Client Secret</label>
|
|
||||||
<input v-model="providerForm.client_secret" type="password" class="form-control" required />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Authorization URL</label>
|
|
||||||
<input v-model="providerForm.authorization_url" type="url" class="form-control" required />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">Token URL</label>
|
|
||||||
<input v-model="providerForm.token_url" type="url" class="form-control" required />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">UserInfo URL</label>
|
|
||||||
<input v-model="providerForm.userinfo_url" type="url" class="form-control" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label">ID Token Field</label>
|
|
||||||
<input v-model="providerForm.id_token_claim" type="text" class="form-control" placeholder="id_token" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">Scopes</label>
|
|
||||||
<input v-model="providerForm.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12 form-check ms-2">
|
|
||||||
<input id="provider-active" v-model="providerForm.is_active" type="checkbox" class="form-check-input" />
|
|
||||||
<label for="provider-active" class="form-check-label">Provider is active</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 d-flex justify-content-end">
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="submittingProvider">
|
|
||||||
{{ submittingProvider ? "Saving..." : "Add Provider" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -305,53 +254,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
|
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
|
||||||
|
|
||||||
<teleport to="body">
|
<AdminGroupModal
|
||||||
<div v-if="showGroupModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeGroupModal">
|
v-if="showGroupModal"
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
:mode="groupModalMode"
|
||||||
<div class="modal-content">
|
:group="selectedGroup"
|
||||||
<div class="modal-header">
|
:is-system-group="isEditingSystemGroup"
|
||||||
<h5 class="modal-title">{{ groupModalMode === "create" ? "Create Group" : "Edit Group" }}</h5>
|
:submitting="submittingGroupModal"
|
||||||
<button type="button" class="btn-close" aria-label="Close" @click="closeGroupModal"></button>
|
@close="closeGroupModal"
|
||||||
</div>
|
@submit="submitGroupModal"
|
||||||
<form @submit.prevent="submitGroupModal">
|
/>
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Group name</label>
|
|
||||||
<input v-model="groupModalForm.name" class="form-control" type="text" required :disabled="isEditingSystemGroup" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<AdminUserModal v-if="showUserModal && selectedUser" :user="selectedUser" :groups="groups" :submitting="submittingUserModal" @close="closeUserModal" @submit="submitUserModal" />
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<input v-model="groupModalForm.description" class="form-control" type="text" :disabled="isEditingSystemGroup" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<AdminProviderModal
|
||||||
<label class="form-label">Permissions (one per line)</label>
|
v-if="showProviderModal"
|
||||||
<textarea
|
:mode="providerModalMode"
|
||||||
v-model="groupModalForm.permissionsText"
|
:provider="selectedProvider"
|
||||||
class="form-control permissions-textarea"
|
:submitting="submittingProviderModal"
|
||||||
rows="10"
|
@close="closeProviderModal"
|
||||||
placeholder="space.create space.project_docs.category.create space.project_docs.*"
|
@submit="submitProviderModal"
|
||||||
:disabled="isEditingSystemGroup"
|
/>
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" @click="closeGroupModal">Cancel</button>
|
|
||||||
<button v-if="!isEditingSystemGroup" type="submit" class="btn btn-primary" :disabled="submittingGroupModal">
|
|
||||||
{{ submittingGroupModal ? "Saving..." : groupModalMode === "create" ? "Create Group" : "Save Changes" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showGroupModal" class="modal-backdrop fade show"></div>
|
|
||||||
</teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -359,14 +287,34 @@ import { computed, onMounted, ref } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
||||||
|
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
||||||
|
import AdminUserModal from "../components/AdminUserModal.vue";
|
||||||
|
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const activeTab = ref("users");
|
const activeTab = ref("users");
|
||||||
|
const showMobileSidebar = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const successMessage = ref("");
|
const successMessage = ref("");
|
||||||
|
|
||||||
|
const adminTabs = [
|
||||||
|
{ id: "users", label: "Users" },
|
||||||
|
{ id: "groups", label: "Groups" },
|
||||||
|
{ id: "spaces", label: "Spaces" },
|
||||||
|
{ id: "providers", label: "Identity Providers" },
|
||||||
|
{ id: "featureFlags", label: "Feature Flags" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectTab = (tabId) => {
|
||||||
|
activeTab.value = tabId;
|
||||||
|
showMobileSidebar.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const users = ref([]);
|
const users = ref([]);
|
||||||
const loadingUsers = ref(false);
|
const loadingUsers = ref(false);
|
||||||
|
const showUserModal = ref(false);
|
||||||
|
const submittingUserModal = ref(false);
|
||||||
|
const selectedUser = ref(null);
|
||||||
|
|
||||||
const groups = ref([]);
|
const groups = ref([]);
|
||||||
const loadingGroups = ref(false);
|
const loadingGroups = ref(false);
|
||||||
@@ -374,11 +322,7 @@ const showGroupModal = ref(false);
|
|||||||
const groupModalMode = ref("create");
|
const groupModalMode = ref("create");
|
||||||
const editingGroupId = ref("");
|
const editingGroupId = ref("");
|
||||||
const submittingGroupModal = ref(false);
|
const submittingGroupModal = ref(false);
|
||||||
const groupModalForm = ref({
|
const selectedGroup = ref(null);
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
permissionsText: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const spaces = ref([]);
|
const spaces = ref([]);
|
||||||
const loadingSpaces = ref(false);
|
const loadingSpaces = ref(false);
|
||||||
@@ -387,19 +331,10 @@ const selectedSpace = ref(null);
|
|||||||
|
|
||||||
const providers = ref([]);
|
const providers = ref([]);
|
||||||
const loadingProviders = ref(false);
|
const loadingProviders = ref(false);
|
||||||
const submittingProvider = ref(false);
|
const showProviderModal = ref(false);
|
||||||
const providerForm = ref({
|
const providerModalMode = ref("create");
|
||||||
name: "",
|
const selectedProvider = ref(null);
|
||||||
type: "oidc",
|
const submittingProviderModal = ref(false);
|
||||||
client_id: "",
|
|
||||||
client_secret: "",
|
|
||||||
authorization_url: "",
|
|
||||||
token_url: "",
|
|
||||||
userinfo_url: "",
|
|
||||||
id_token_claim: "id_token",
|
|
||||||
scopes: "openid, profile, email",
|
|
||||||
is_active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadingFeatureFlags = ref(false);
|
const loadingFeatureFlags = ref(false);
|
||||||
const savingFeatureFlags = ref(false);
|
const savingFeatureFlags = ref(false);
|
||||||
@@ -439,8 +374,10 @@ const loadUsers = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUserGroups = async (userId, groupIds) => {
|
const updateUserGroups = async (userId, groupIds, options = {}) => {
|
||||||
|
if (!options.silent) {
|
||||||
clearMessages();
|
clearMessages();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds });
|
const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds });
|
||||||
const updatedUser = response.data;
|
const updatedUser = response.data;
|
||||||
@@ -448,26 +385,70 @@ const updateUserGroups = async (userId, groupIds) => {
|
|||||||
if (userIndex !== -1) {
|
if (userIndex !== -1) {
|
||||||
users.value[userIndex] = { ...users.value[userIndex], ...updatedUser };
|
users.value[userIndex] = { ...users.value[userIndex], ...updatedUser };
|
||||||
}
|
}
|
||||||
|
if (!options.silent) {
|
||||||
successMessage.value = "User groups updated.";
|
successMessage.value = "User groups updated.";
|
||||||
|
}
|
||||||
|
return updatedUser;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to update user groups.";
|
error.value = e.response?.data || "Failed to update user groups.";
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetGroupModalForm = () => {
|
const getUserGroupSummary = (user) => {
|
||||||
groupModalForm.value = {
|
const ids = user?.group_ids || [];
|
||||||
name: "",
|
if (!ids.length) {
|
||||||
description: "",
|
return "No groups";
|
||||||
permissionsText: "",
|
}
|
||||||
|
const names = ids.map((groupID) => groups.value.find((group) => group.id === groupID)?.name).filter(Boolean);
|
||||||
|
return names.length ? names.join(", ") : "No groups";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditUserModal = (user) => {
|
||||||
|
selectedUser.value = { ...user };
|
||||||
|
showUserModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeUserModal = () => {
|
||||||
|
showUserModal.value = false;
|
||||||
|
submittingUserModal.value = false;
|
||||||
|
selectedUser.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitUserModal = async ({ group_ids }) => {
|
||||||
|
submittingUserModal.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await updateUserGroups(selectedUser.value.id, group_ids, { silent: true });
|
||||||
|
successMessage.value = "User updated.";
|
||||||
|
closeUserModal();
|
||||||
|
} catch {
|
||||||
|
// error message handled in updateUserGroups
|
||||||
|
} finally {
|
||||||
|
submittingUserModal.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (user) => {
|
||||||
|
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/admin/users/${user.id}`);
|
||||||
|
users.value = users.value.filter((item) => item.id !== user.id);
|
||||||
|
successMessage.value = `User "${user.username}" deleted.`;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to delete user.";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEditingSystemGroup = computed(() => {
|
const isEditingSystemGroup = computed(() => {
|
||||||
if (groupModalMode.value !== "edit") {
|
if (groupModalMode.value !== "edit") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const group = groups.value.find((item) => item.id === editingGroupId.value);
|
return !!selectedGroup.value?.is_system;
|
||||||
return !!group?.is_system;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const splitPermissionsByNewline = (raw) =>
|
const splitPermissionsByNewline = (raw) =>
|
||||||
@@ -479,24 +460,21 @@ const splitPermissionsByNewline = (raw) =>
|
|||||||
const openCreateGroupModal = () => {
|
const openCreateGroupModal = () => {
|
||||||
groupModalMode.value = "create";
|
groupModalMode.value = "create";
|
||||||
editingGroupId.value = "";
|
editingGroupId.value = "";
|
||||||
resetGroupModalForm();
|
selectedGroup.value = null;
|
||||||
showGroupModal.value = true;
|
showGroupModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditGroupModal = (group) => {
|
const openEditGroupModal = (group) => {
|
||||||
groupModalMode.value = "edit";
|
groupModalMode.value = "edit";
|
||||||
editingGroupId.value = group.id;
|
editingGroupId.value = group.id;
|
||||||
groupModalForm.value = {
|
selectedGroup.value = { ...group };
|
||||||
name: group.name || "",
|
|
||||||
description: group.description || "",
|
|
||||||
permissionsText: (group.permissions || []).join("\n"),
|
|
||||||
};
|
|
||||||
showGroupModal.value = true;
|
showGroupModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeGroupModal = () => {
|
const closeGroupModal = () => {
|
||||||
showGroupModal.value = false;
|
showGroupModal.value = false;
|
||||||
submittingGroupModal.value = false;
|
submittingGroupModal.value = false;
|
||||||
|
selectedGroup.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadGroups = async () => {
|
const loadGroups = async () => {
|
||||||
@@ -512,14 +490,14 @@ const loadGroups = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitGroupModal = async () => {
|
const submitGroupModal = async (formData) => {
|
||||||
submittingGroupModal.value = true;
|
submittingGroupModal.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: groupModalForm.value.name,
|
name: formData.name,
|
||||||
description: groupModalForm.value.description,
|
description: formData.description,
|
||||||
permissions: splitPermissionsByNewline(groupModalForm.value.permissionsText),
|
permissions: splitPermissionsByNewline(formData.permissionsText),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (groupModalMode.value === "create") {
|
if (groupModalMode.value === "create") {
|
||||||
@@ -531,7 +509,6 @@ const submitGroupModal = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeGroupModal();
|
closeGroupModal();
|
||||||
resetGroupModalForm();
|
|
||||||
await Promise.all([loadGroups(), loadUsers()]);
|
await Promise.all([loadGroups(), loadUsers()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || `Failed to ${groupModalMode.value === "create" ? "create" : "update"} group.`;
|
error.value = e.response?.data || `Failed to ${groupModalMode.value === "create" ? "create" : "update"} group.`;
|
||||||
@@ -540,6 +517,24 @@ const submitGroupModal = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteGroup = async (group) => {
|
||||||
|
if (group.is_system) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/admin/groups/${group.id}`);
|
||||||
|
successMessage.value = `Group "${group.name}" deleted.`;
|
||||||
|
await Promise.all([loadGroups(), loadUsers()]);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to delete group.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadSpaces = async () => {
|
const loadSpaces = async () => {
|
||||||
loadingSpaces.value = true;
|
loadingSpaces.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
@@ -574,21 +569,43 @@ const onSpaceDeleted = (deletedSpace) => {
|
|||||||
successMessage.value = `Space "${deletedSpace.name}" deleted.`;
|
successMessage.value = `Space "${deletedSpace.name}" deleted.`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`;
|
const openCreateProviderModal = () => {
|
||||||
|
providerModalMode.value = "create";
|
||||||
const resetProviderForm = () => {
|
selectedProvider.value = null;
|
||||||
providerForm.value = {
|
showProviderModal.value = true;
|
||||||
name: "",
|
|
||||||
type: "oidc",
|
|
||||||
client_id: "",
|
|
||||||
client_secret: "",
|
|
||||||
authorization_url: "",
|
|
||||||
token_url: "",
|
|
||||||
userinfo_url: "",
|
|
||||||
id_token_claim: "id_token",
|
|
||||||
scopes: "openid, profile, email",
|
|
||||||
is_active: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditProviderModal = (provider) => {
|
||||||
|
providerModalMode.value = "edit";
|
||||||
|
selectedProvider.value = { ...provider };
|
||||||
|
showProviderModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeProviderModal = () => {
|
||||||
|
showProviderModal.value = false;
|
||||||
|
submittingProviderModal.value = false;
|
||||||
|
selectedProvider.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitProviderModal = async (formData) => {
|
||||||
|
submittingProviderModal.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
if (providerModalMode.value === "create") {
|
||||||
|
await apiClient.post("/api/v1/admin/auth/providers", formData);
|
||||||
|
successMessage.value = "Provider added.";
|
||||||
|
} else {
|
||||||
|
await apiClient.put(`/api/v1/admin/auth/providers/${selectedProvider.value.id}`, formData);
|
||||||
|
successMessage.value = "Provider updated.";
|
||||||
|
}
|
||||||
|
|
||||||
|
closeProviderModal();
|
||||||
|
await loadProviders();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || `Failed to ${providerModalMode.value === "create" ? "create" : "update"} provider.`;
|
||||||
|
} finally {
|
||||||
|
submittingProviderModal.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadProviders = async () => {
|
const loadProviders = async () => {
|
||||||
@@ -604,24 +621,18 @@ const loadProviders = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createProvider = async () => {
|
const deleteProvider = async (provider) => {
|
||||||
submittingProvider.value = true;
|
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
await apiClient.post("/api/v1/admin/auth/providers", {
|
await apiClient.delete(`/api/v1/admin/auth/providers/${provider.id}`);
|
||||||
...providerForm.value,
|
providers.value = providers.value.filter((item) => item.id !== provider.id);
|
||||||
scopes: providerForm.value.scopes
|
successMessage.value = `Provider "${provider.name}" deleted.`;
|
||||||
.split(",")
|
|
||||||
.map((scope) => scope.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
});
|
|
||||||
successMessage.value = "Provider added.";
|
|
||||||
resetProviderForm();
|
|
||||||
await loadProviders();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to create provider.";
|
error.value = e.response?.data || "Failed to delete provider.";
|
||||||
} finally {
|
|
||||||
submittingProvider.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -691,15 +702,203 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-page {
|
.admin-page {
|
||||||
max-width: 1100px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-right: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-inner {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .nav-link {
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .nav-link:hover {
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav .nav-link.active {
|
||||||
|
background: #212529;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-section {
|
.admin-section {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permissions-textarea {
|
.users-list .list-group-item {
|
||||||
font-family: "Courier New", monospace;
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions-stack {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta-value {
|
||||||
|
color: #495057;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta-item-groups {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.user-meta-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta-item-groups {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.admin-shell {
|
||||||
|
display: block;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 1400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(82vw, 320px);
|
||||||
|
z-index: 1410;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
border-right: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-inner {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions-stack {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,15 +15,12 @@
|
|||||||
*/
|
*/
|
||||||
export function preprocessMarkdown(content) {
|
export function preprocessMarkdown(content) {
|
||||||
if (!content) return content;
|
if (!content) return content;
|
||||||
return content.replace(
|
return content.replace(/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi, (_, alt, url, title, w, h) => {
|
||||||
/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi,
|
|
||||||
(_, alt, url, title, w, h) => {
|
|
||||||
const safeAlt = alt.replace(/"/g, """);
|
const safeAlt = alt.replace(/"/g, """);
|
||||||
let attrs = `src="${url}" alt="${safeAlt}"`;
|
let attrs = `src="${url}" alt="${safeAlt}"`;
|
||||||
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
||||||
if (w) attrs += ` width="${w}"`;
|
if (w) attrs += ` width="${w}"`;
|
||||||
if (h) attrs += ` height="${h}"`;
|
if (h) attrs += ` height="${h}"`;
|
||||||
return `<img ${attrs}>`;
|
return `<img ${attrs}>`;
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user