From f0701b990211a66360b1d9f0bdcaf9535f21d7c3 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 26 Jan 2026 19:57:45 +0100 Subject: [PATCH 1/2] feat(auth): Add RBAC user interface for users and API keys Add frontend support for multi-user authentication with RBAC: - UsersView: User management with deployment assignment panel - APIKeysView: API key creation, listing, and revocation - LoginView: Toggle between username/password and API key login - DashboardLayout: User info display, role-based nav items Updated stores and services: - auth store: currentUser, permissions, hasPermission() - users store: User and API key state management - api service: usersApi, apiKeysApi, deploymentUsersApi Types added for User, APIKey, UserDeploymentAccess, Permission. Signed-off-by: nfebe --- src/layouts/DashboardLayout.vue | 62 ++- src/router/index.ts | 12 + src/services/api.ts | 73 ++++ src/stores/auth.ts | 101 +++++ src/stores/users.ts | 178 ++++++++ src/types/index.ts | 65 +++ src/views/APIKeysView.vue | 637 +++++++++++++++++++++++++++++ src/views/LoginView.vue | 120 +++++- src/views/UsersView.vue | 695 ++++++++++++++++++++++++++++++++ 9 files changed, 1936 insertions(+), 7 deletions(-) create mode 100644 src/stores/users.ts create mode 100644 src/views/APIKeysView.vue create mode 100644 src/views/UsersView.vue diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 4edb1eb..69086c0 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -182,6 +182,8 @@ @@ -215,6 +217,15 @@ +
{{ agentOnline ? "Connected" : "Disconnected" }} @@ -271,11 +282,13 @@ import { ref, reactive, computed, onMounted } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useStatsStore } from "@/stores/stats"; +import { useAuthStore } from "@/stores/auth"; import Logo from "@/components/base/Logo.vue"; const route = useRoute(); const router = useRouter(); const statsStore = useStatsStore(); +const authStore = useAuthStore(); const sidebarCollapsed = ref(false); const isRefreshing = ref(false); @@ -344,6 +357,8 @@ const currentPageTitle = computed(() => { templates: "Templates", marketplace: "App Marketplace", settings: "Settings", + users: "Users", + "api-keys": "API Keys", }; return titles[route.name as string] || "Dashboard"; }); @@ -379,7 +394,7 @@ const breadcrumbs = computed(() => { } else if (["apps", "marketplace"].includes(routeName)) { crumbs.push({ label: "Apps", path: "" }); crumbs.push({ label: currentPageTitle.value, path: "" }); - } else if (routeName === "settings") { + } else if (["settings", "users", "api-keys"].includes(routeName)) { crumbs.push({ label: "Administration", path: "" }); crumbs.push({ label: currentPageTitle.value, path: "" }); } else if (routeName !== "home") { @@ -402,12 +417,13 @@ const refreshAll = async () => { }; const handleLogout = () => { - localStorage.removeItem("auth_token"); + authStore.logout(); router.push("/login"); }; onMounted(() => { statsStore.fetchAll(); + authStore.fetchCurrentUser(); setInterval(() => statsStore.fetchAll(), 15000); }); @@ -628,6 +644,48 @@ onMounted(() => { background: #ef4444; } +.user-info { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + margin-bottom: 0.75rem; +} + +.user-avatar { + width: 32px; + height: 32px; + background: #3b82f6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.user-avatar i { + font-size: 0.875rem; + color: white; +} + +.user-details { + display: flex; + flex-direction: column; +} + +.user-name { + font-size: 0.875rem; + font-weight: 500; + color: white; +} + +.user-role { + font-size: 0.75rem; + color: #94a3b8; + text-transform: capitalize; +} + .agent-status { display: flex; align-items: center; diff --git a/src/router/index.ts b/src/router/index.ts index 9b8188c..f9d2761 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -124,6 +124,18 @@ const routes: RouteRecordRaw[] = [ name: "dns-external", component: () => import("@/views/DnsExternalView.vue"), }, + { + path: "users", + name: "users", + component: () => import("@/views/UsersView.vue"), + meta: { permission: "users:read" }, + }, + { + path: "api-keys", + name: "api-keys", + component: () => import("@/views/APIKeysView.vue"), + meta: { permission: "apikeys:read" }, + }, ], }, ]; diff --git a/src/services/api.ts b/src/services/api.ts index 5f6b6ff..c7120b1 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1100,3 +1100,76 @@ export const powerDnsApi = { updateRecords: (zoneId: string, rrsets: PowerDNSRRSet[]) => apiClient.patch(`/dns/powerdns/zones/${zoneId}`, { rrsets }), }; + +import type { User, APIKey, UserRole, UserDeploymentAccess } from "@/types"; + +export const usersApi = { + list: () => apiClient.get<{ users: User[] }>("/users"), + + get: (id: number) => + apiClient.get<{ user: User; deployments: UserDeploymentAccess[] }>(`/users/${id}`), + + create: (data: { username: string; email?: string; password: string; role: UserRole }) => + apiClient.post<{ user: User }>("/users", data), + + update: (id: number, data: { username?: string; email?: string; role?: UserRole; is_active?: boolean }) => + apiClient.put<{ user: User }>(`/users/${id}`, data), + + delete: (id: number) => apiClient.delete(`/users/${id}`), + + me: () => + apiClient.get<{ user: User; permissions: string[]; deployments: UserDeploymentAccess[] }>("/users/me"), + + updateMe: (data: { email?: string }) => apiClient.put<{ user: User }>("/users/me", data), + + updatePassword: (data: { current_password: string; new_password: string }) => + apiClient.put("/users/me/password", data), + + getDeployments: (userId: number) => + apiClient.get<{ deployments: UserDeploymentAccess[] }>(`/users/${userId}/deployments`), + + assignDeployment: (userId: number, data: { deployment_name: string; access_level: string }) => + apiClient.post(`/users/${userId}/deployments`, data), + + updateDeployment: (userId: number, deploymentName: string, data: { access_level: string }) => + apiClient.put(`/users/${userId}/deployments/${deploymentName}`, data), + + removeDeployment: (userId: number, deploymentName: string) => + apiClient.delete(`/users/${userId}/deployments/${deploymentName}`), +}; + +export const apiKeysApi = { + list: () => apiClient.get<{ api_keys: APIKey[] }>("/apikeys"), + + get: (id: number) => apiClient.get<{ api_key: APIKey }>(`/apikeys/${id}`), + + create: (data: { + name: string; + description?: string; + role?: UserRole; + permissions?: string[]; + deployments?: string[]; + expires_in?: number; + user_id?: number; + }) => apiClient.post<{ api_key: APIKey; message: string }>("/apikeys", data), + + delete: (id: number) => apiClient.delete(`/apikeys/${id}`), + + revoke: (id: number) => apiClient.post(`/apikeys/${id}/revoke`), +}; + +export const deploymentUsersApi = { + getUsers: (deploymentName: string) => + apiClient.get<{ + deployment_name: string; + users: Array<{ + user_id: number; + username: string; + email?: string; + role: UserRole; + access_level: string; + granted_by?: number; + created_at: string; + }>; + }>(`/deployments/${deploymentName}/users`), +}; diff --git a/src/stores/auth.ts b/src/stores/auth.ts index c0cf704..8c9a0d6 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,6 +1,7 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; import axios from "axios"; +import type { User, Permission, UserDeploymentAccess } from "@/types"; const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL || "/api", @@ -14,8 +15,35 @@ export const useAuthStore = defineStore("auth", () => { const authEnabled = ref(null); const loading = ref(false); const error = ref(""); + const currentUser = ref(null); + const permissions = ref([]); + const deploymentAccess = ref([]); const isAuthenticated = computed(() => !!token.value); + const isAdmin = computed(() => currentUser.value?.role === "admin"); + const isOperator = computed(() => currentUser.value?.role === "operator"); + const isViewer = computed(() => currentUser.value?.role === "viewer"); + + const hasPermission = (permission: Permission): boolean => { + if (currentUser.value?.role === "admin") { + return true; + } + return permissions.value.includes(permission); + }; + + const canAccessDeployment = (deploymentName: string, level: "read" | "write" | "admin"): boolean => { + if (currentUser.value?.role === "admin") { + return true; + } + + const access = deploymentAccess.value.find((d) => d.deployment_name === deploymentName); + if (!access) { + return false; + } + + const levels = { read: 1, write: 2, admin: 3 }; + return levels[access.access_level] >= levels[level]; + }; const checkAuthStatus = async () => { try { @@ -36,6 +64,17 @@ export const useAuthStore = defineStore("auth", () => { const response = await apiClient.post("/auth/login", { api_key: apiKey }); token.value = response.data.token; localStorage.setItem("auth_token", response.data.token); + + if (response.data.user) { + currentUser.value = response.data.user; + } + if (response.data.permissions) { + permissions.value = response.data.permissions; + } + if (response.data.deployments) { + deploymentAccess.value = response.data.deployments; + } + return true; } catch (e: any) { error.value = e.response?.data?.error || "Invalid API key"; @@ -45,8 +84,60 @@ export const useAuthStore = defineStore("auth", () => { } }; + const loginWithCredentials = async (username: string, password: string) => { + loading.value = true; + error.value = ""; + + try { + const response = await apiClient.post("/auth/login", { username, password }); + token.value = response.data.token; + localStorage.setItem("auth_token", response.data.token); + + if (response.data.user) { + currentUser.value = response.data.user; + } + if (response.data.permissions) { + permissions.value = response.data.permissions; + } + if (response.data.deployments) { + deploymentAccess.value = response.data.deployments; + } + + return true; + } catch (e: any) { + error.value = e.response?.data?.error || "Invalid username or password"; + return false; + } finally { + loading.value = false; + } + }; + + const fetchCurrentUser = async () => { + if (!token.value) return; + + try { + const authClient = axios.create({ + baseURL: import.meta.env.VITE_API_URL || "/api", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token.value}`, + }, + }); + + const response = await authClient.get("/users/me"); + currentUser.value = response.data.user; + permissions.value = response.data.permissions || []; + deploymentAccess.value = response.data.deployments || []; + } catch { + // User endpoint may not exist if using legacy auth + } + }; + const logout = () => { token.value = null; + currentUser.value = null; + permissions.value = []; + deploymentAccess.value = []; localStorage.removeItem("auth_token"); }; @@ -62,9 +153,19 @@ export const useAuthStore = defineStore("auth", () => { authEnabled, loading, error, + currentUser, + permissions, + deploymentAccess, isAuthenticated, + isAdmin, + isOperator, + isViewer, + hasPermission, + canAccessDeployment, checkAuthStatus, login, + loginWithCredentials, + fetchCurrentUser, logout, getAuthHeader, }; diff --git a/src/stores/users.ts b/src/stores/users.ts new file mode 100644 index 0000000..468b182 --- /dev/null +++ b/src/stores/users.ts @@ -0,0 +1,178 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { User, APIKey, UserRole, UserDeploymentAccess } from "@/types"; +import { usersApi, apiKeysApi } from "@/services/api"; + +export const useUsersStore = defineStore("users", () => { + const users = ref([]); + const apiKeys = ref([]); + const loading = ref(false); + const error = ref(""); + + const fetchUsers = async () => { + loading.value = true; + error.value = ""; + try { + const response = await usersApi.list(); + users.value = response.data.users; + } catch (e: any) { + error.value = e.response?.data?.error || "Failed to fetch users"; + } finally { + loading.value = false; + } + }; + + const getUser = async (id: number) => { + try { + const response = await usersApi.get(id); + return response.data; + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to get user"); + } + }; + + const createUser = async (data: { + username: string; + email?: string; + password: string; + role: UserRole; + }) => { + try { + const response = await usersApi.create(data); + users.value.push(response.data.user); + return response.data.user; + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to create user"); + } + }; + + const updateUser = async ( + id: number, + data: { username?: string; email?: string; role?: UserRole; is_active?: boolean } + ) => { + try { + const response = await usersApi.update(id, data); + const index = users.value.findIndex((u) => u.id === id); + if (index !== -1) { + users.value[index] = response.data.user; + } + return response.data.user; + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to update user"); + } + }; + + const deleteUser = async (id: number) => { + try { + await usersApi.delete(id); + users.value = users.value.filter((u) => u.id !== id); + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to delete user"); + } + }; + + const getUserDeployments = async (userId: number) => { + try { + const response = await usersApi.getDeployments(userId); + return response.data.deployments as UserDeploymentAccess[]; + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to get user deployments"); + } + }; + + const assignDeployment = async (userId: number, deploymentName: string, accessLevel: string) => { + try { + await usersApi.assignDeployment(userId, { deployment_name: deploymentName, access_level: accessLevel }); + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to assign deployment"); + } + }; + + const updateDeploymentAccess = async (userId: number, deploymentName: string, accessLevel: string) => { + try { + await usersApi.updateDeployment(userId, deploymentName, { access_level: accessLevel }); + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to update deployment access"); + } + }; + + const removeDeploymentAccess = async (userId: number, deploymentName: string) => { + try { + await usersApi.removeDeployment(userId, deploymentName); + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to remove deployment access"); + } + }; + + const fetchAPIKeys = async () => { + loading.value = true; + error.value = ""; + try { + const response = await apiKeysApi.list(); + apiKeys.value = response.data.api_keys; + } catch (e: any) { + error.value = e.response?.data?.error || "Failed to fetch API keys"; + } finally { + loading.value = false; + } + }; + + const createAPIKey = async (data: { + name: string; + description?: string; + role?: UserRole; + permissions?: string[]; + deployments?: string[]; + expires_in?: number; + user_id?: number; + }) => { + try { + const response = await apiKeysApi.create(data); + apiKeys.value.push(response.data.api_key); + return response.data; + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to create API key"); + } + }; + + const deleteAPIKey = async (id: number) => { + try { + await apiKeysApi.delete(id); + apiKeys.value = apiKeys.value.filter((k) => k.id !== id); + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to delete API key"); + } + }; + + const revokeAPIKey = async (id: number) => { + try { + await apiKeysApi.revoke(id); + const index = apiKeys.value.findIndex((k) => k.id === id); + if (index !== -1) { + apiKeys.value[index].is_active = false; + } + } catch (e: any) { + throw new Error(e.response?.data?.error || "Failed to revoke API key"); + } + }; + + return { + users, + apiKeys, + loading, + error, + fetchUsers, + getUser, + createUser, + updateUser, + deleteUser, + getUserDeployments, + assignDeployment, + updateDeploymentAccess, + removeDeploymentAccess, + fetchAPIKeys, + createAPIKey, + deleteAPIKey, + revokeAPIKey, + }; +}); diff --git a/src/types/index.ts b/src/types/index.ts index 57c0bde..7be640d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -206,3 +206,68 @@ export interface DeploymentRateLimit { burst: number; enabled: boolean; } + +export type UserRole = "admin" | "operator" | "viewer"; + +export interface User { + id: number; + uid: string; + username: string; + email?: string; + role: UserRole; + is_active: boolean; + created_at: string; + updated_at: string; + last_login_at?: string; + deployments?: UserDeploymentAccess[]; +} + +export interface UserDeploymentAccess { + deployment_name: string; + access_level: "read" | "write" | "admin"; + granted_by?: number; + created_at: string; +} + +export interface APIKey { + id: number; + key_id: string; + user_id: number; + name: string; + description?: string; + key_prefix: string; + role?: UserRole; + permissions?: string[]; + deployments?: string[]; + expires_at?: string; + last_used_at?: string; + last_used_ip?: string; + is_active: boolean; + created_at: string; + key?: string; +} + +export type Permission = + | "deployments:read" + | "deployments:write" + | "deployments:delete" + | "certificates:read" + | "certificates:write" + | "certificates:delete" + | "networks:read" + | "networks:write" + | "networks:delete" + | "security:read" + | "security:write" + | "backups:read" + | "backups:write" + | "backups:delete" + | "users:read" + | "users:write" + | "users:delete" + | "apikeys:read" + | "apikeys:write" + | "apikeys:delete" + | "settings:read" + | "settings:write" + | "audit:read"; diff --git a/src/views/APIKeysView.vue b/src/views/APIKeysView.vue new file mode 100644 index 0000000..7a52c3c --- /dev/null +++ b/src/views/APIKeysView.vue @@ -0,0 +1,637 @@ + + + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 3a5d8d5..898320c 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -26,10 +26,71 @@