Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6774c401bf | ||
|
|
1f1fd90890 |
@@ -117,6 +117,8 @@ func main() {
|
||||
adminService := services.NewAdminService(
|
||||
db.UserRepo,
|
||||
db.GroupRepo,
|
||||
db.ProviderRepo,
|
||||
db.LinkRepo,
|
||||
db.SpaceRepo,
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
@@ -255,10 +257,12 @@ func main() {
|
||||
})
|
||||
})
|
||||
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("/groups", adminHandler.ListGroups).Methods("GET")
|
||||
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
|
||||
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/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
|
||||
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
|
||||
@@ -270,6 +274,8 @@ func main() {
|
||||
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
||||
// manage identity providers — admin-only
|
||||
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
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
type FeatureFlagsDTO struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
type AdminService struct {
|
||||
userRepo repositories.UserRepository
|
||||
groupRepo repositories.GroupRepository
|
||||
providerRepo repositories.AuthProviderRepository
|
||||
linkRepo repositories.UserProviderLinkRepository
|
||||
spaceRepo repositories.SpaceRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
@@ -30,6 +32,8 @@ type AdminService struct {
|
||||
func NewAdminService(
|
||||
userRepo repositories.UserRepository,
|
||||
groupRepo repositories.GroupRepository,
|
||||
providerRepo repositories.AuthProviderRepository,
|
||||
linkRepo repositories.UserProviderLinkRepository,
|
||||
spaceRepo repositories.SpaceRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
@@ -41,6 +45,8 @@ func NewAdminService(
|
||||
return &AdminService{
|
||||
userRepo: userRepo,
|
||||
groupRepo: groupRepo,
|
||||
providerRepo: providerRepo,
|
||||
linkRepo: linkRepo,
|
||||
spaceRepo: spaceRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
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
|
||||
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
|
||||
flags, err := s.GetFeatureFlags(ctx)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"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})
|
||||
}
|
||||
|
||||
// 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
|
||||
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
|
||||
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentUser && isAdminRoute" class="container py-4">
|
||||
<div v-else-if="currentUser && isAdminRoute" class="admin-route-view">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
@@ -965,6 +965,16 @@ const logout = () => {
|
||||
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) {
|
||||
.app-navbar {
|
||||
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>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" 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 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 modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Space</h5>
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
@@ -252,3 +252,30 @@ const deleteSpace = async () => {
|
||||
}
|
||||
};
|
||||
</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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,12 @@
|
||||
*/
|
||||
export function preprocessMarkdown(content) {
|
||||
if (!content) return content;
|
||||
return content.replace(
|
||||
/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi,
|
||||
(_, alt, url, title, w, h) => {
|
||||
const safeAlt = alt.replace(/"/g, """);
|
||||
let attrs = `src="${url}" alt="${safeAlt}"`;
|
||||
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
||||
if (w) attrs += ` width="${w}"`;
|
||||
if (h) attrs += ` height="${h}"`;
|
||||
return `<img ${attrs}>`;
|
||||
},
|
||||
);
|
||||
return content.replace(/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi, (_, alt, url, title, w, h) => {
|
||||
const safeAlt = alt.replace(/"/g, """);
|
||||
let attrs = `src="${url}" alt="${safeAlt}"`;
|
||||
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
||||
if (w) attrs += ` width="${w}"`;
|
||||
if (h) attrs += ` height="${h}"`;
|
||||
return `<img ${attrs}>`;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user