feat: updated identity providers in admin panel
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 49s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 49s
This commit is contained in:
@@ -274,6 +274,7 @@ 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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
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,34 +1,40 @@
|
||||
<template>
|
||||
<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>
|
||||
<h2 class="mb-1">Admin Panel</h2>
|
||||
<p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
<div class="admin-shell">
|
||||
<div v-if="showMobileSidebar" class="admin-sidebar-backdrop" @click="showMobileSidebar = false"></div>
|
||||
|
||||
<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">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
@@ -134,80 +140,26 @@
|
||||
|
||||
<section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">Configured Providers</h5>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<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-primary" @click="openCreateProviderModal"><i class="mdi mdi-plus me-1" aria-hidden="true"></i>Add Provider</button>
|
||||
</div>
|
||||
</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 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>
|
||||
<div class="fw-semibold">{{ provider.name }}</div>
|
||||
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
|
||||
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||
{{ provider.is_active ? "Active" : "Disabled" }}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="deleteProvider(provider)">Delete</button>
|
||||
<span class="fw-semibold">{{ provider.name }}</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="openEditProviderModal(provider)">Edit</button>
|
||||
</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 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>
|
||||
</section>
|
||||
|
||||
@@ -302,6 +254,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
|
||||
@@ -317,6 +271,15 @@
|
||||
/>
|
||||
|
||||
<AdminUserModal v-if="showUserModal && selectedUser" :user="selectedUser" :groups="groups" :submitting="submittingUserModal" @close="closeUserModal" @submit="submitUserModal" />
|
||||
|
||||
<AdminProviderModal
|
||||
v-if="showProviderModal"
|
||||
:mode="providerModalMode"
|
||||
:provider="selectedProvider"
|
||||
:submitting="submittingProviderModal"
|
||||
@close="closeProviderModal"
|
||||
@submit="submitProviderModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -326,12 +289,27 @@ import apiClient from "../services/apiClient";
|
||||
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 activeTab = ref("users");
|
||||
const showMobileSidebar = ref(false);
|
||||
const error = 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 loadingUsers = ref(false);
|
||||
const showUserModal = ref(false);
|
||||
@@ -353,19 +331,10 @@ const selectedSpace = ref(null);
|
||||
|
||||
const providers = ref([]);
|
||||
const loadingProviders = ref(false);
|
||||
const submittingProvider = ref(false);
|
||||
const providerForm = 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 showProviderModal = ref(false);
|
||||
const providerModalMode = ref("create");
|
||||
const selectedProvider = ref(null);
|
||||
const submittingProviderModal = ref(false);
|
||||
|
||||
const loadingFeatureFlags = ref(false);
|
||||
const savingFeatureFlags = ref(false);
|
||||
@@ -600,21 +569,43 @@ const onSpaceDeleted = (deletedSpace) => {
|
||||
successMessage.value = `Space "${deletedSpace.name}" deleted.`;
|
||||
};
|
||||
|
||||
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`;
|
||||
|
||||
const resetProviderForm = () => {
|
||||
providerForm.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,
|
||||
const openCreateProviderModal = () => {
|
||||
providerModalMode.value = "create";
|
||||
selectedProvider.value = null;
|
||||
showProviderModal.value = 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 () => {
|
||||
@@ -630,27 +621,6 @@ const loadProviders = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const createProvider = async () => {
|
||||
submittingProvider.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.post("/api/v1/admin/auth/providers", {
|
||||
...providerForm.value,
|
||||
scopes: providerForm.value.scopes
|
||||
.split(",")
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
successMessage.value = "Provider added.";
|
||||
resetProviderForm();
|
||||
await loadProviders();
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to create provider.";
|
||||
} finally {
|
||||
submittingProvider.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProvider = async (provider) => {
|
||||
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
@@ -732,8 +702,64 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
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 {
|
||||
@@ -810,6 +836,49 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
Reference in New Issue
Block a user