From 62f767474b4c04ad6b77f24348aeabce1bfa9401 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Sat, 28 Feb 2026 23:35:32 +0545 Subject: [PATCH] Feat(Fix): SSH Keys-Expiry+Log; Department+Principal Link; CA Keys mgmt; - Fix Login nav to /profile or / --- src/App.tsx | 21 +- src/components/navigation/AppSidebar.tsx | 5 + src/components/navigation/TopBar.tsx | 10 +- src/lib/api.ts | 246 ++++- src/pages/Index.tsx | 13 +- src/pages/admin/AdminUsersPage.tsx | 376 ++++++++ src/pages/auth/ActivatePage.tsx | 97 ++ src/pages/org/CAsPage.tsx | 728 +++++++++++++++ src/pages/org/DepartmentsPage.tsx | 188 ++-- src/pages/org/PoliciesPage.tsx | 14 +- src/pages/org/PrincipalsPage.tsx | 342 +++---- src/pages/user/SSHKeysPage.tsx | 1046 ++++++++++++++++++++++ 12 files changed, 2850 insertions(+), 236 deletions(-) create mode 100644 src/pages/admin/AdminUsersPage.tsx create mode 100644 src/pages/auth/ActivatePage.tsx create mode 100644 src/pages/org/CAsPage.tsx create mode 100644 src/pages/user/SSHKeysPage.tsx diff --git a/src/App.tsx b/src/App.tsx index f236de1..8e4b6b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,12 +19,14 @@ import InviteAcceptPage from "@/pages/auth/InviteAcceptPage"; import OIDCConsentPage from "@/pages/auth/OIDCConsentPage"; import OIDCErrorPage from "@/pages/auth/OIDCErrorPage"; import OAuthCallbackPage from "@/pages/auth/OAuthCallbackPage"; +import ActivatePage from "@/pages/auth/ActivatePage"; // User pages import ProfilePage from "@/pages/user/ProfilePage"; import SecurityPage from "@/pages/user/SecurityPage"; import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage"; import ActivityPage from "@/pages/user/ActivityPage"; +import SSHKeysPage from "@/pages/user/SSHKeysPage"; // Organization pages import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; @@ -33,9 +35,11 @@ import PoliciesPage from "@/pages/org/PoliciesPage"; import CompliancePage from "@/pages/org/CompliancePage"; import OrgAuditPage from "@/pages/org/OrgAuditPage"; import OIDCClientsPage from "@/pages/org/OIDCClientsPage"; +import CAsPage from "@/pages/org/CAsPage"; import DepartmentsPage from "@/pages/org/DepartmentsPage"; import PrincipalsPage from "@/pages/org/PrincipalsPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage"; +import AdminUsersPage from "@/pages/admin/AdminUsersPage"; import NotFound from "@/pages/NotFound"; import ApiDevTools from "@/components/dev/ApiDevTools"; @@ -68,7 +72,16 @@ const App = () => ( ); // Separate component so AuthProvider can use useNavigate -import { AuthProvider } from "@/contexts/AuthContext"; +import { AuthProvider, useAuth } from "@/contexts/AuthContext"; +import { Navigate } from "react-router-dom"; + +/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */ +function GuestRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + if (isLoading) return null; // wait for auth state to resolve + if (isAuthenticated) return ; + return <>{children}; +} function AppRoutes() { return ( @@ -79,7 +92,7 @@ function AppRoutes() { {/* Public routes */} }> - } /> + } /> } /> } /> } /> @@ -88,6 +101,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Protected routes - handles auth and MFA enforcement */} @@ -97,6 +111,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Organization routes */} } /> @@ -107,9 +122,11 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Admin routes */} } /> + } /> {/* Catch-all */} diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index ad4fd47..b42b953 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -12,6 +12,8 @@ import { Layers, GitBranch, ScrollText, + Terminal, + ShieldCheck, } from "lucide-react"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { NavLink } from "@/components/NavLink"; @@ -33,6 +35,7 @@ import { cn } from "@/lib/utils"; const userNavItems = [ { title: "Profile", url: "/profile", icon: User }, { title: "Security", url: "/security", icon: Shield }, + { title: "SSH Keys", url: "/ssh-keys", icon: Terminal }, { title: "Linked Accounts", url: "/linked-accounts", icon: Link2 }, { title: "Activity", url: "/activity", icon: Activity }, ]; @@ -48,6 +51,8 @@ const orgNavItems = [ const adminNavItems = [ { title: "OIDC Clients", url: "/org/clients", icon: Key }, + { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, + // { title: "Users", url: "/admin/users", icon: Users }, { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index ffd3e5a..c48941a 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -19,16 +19,12 @@ import { ComplianceBanner } from "@/components/auth/ComplianceBanner"; export function TopBar() { const navigate = useNavigate(); - const { user, isAuthenticated, mfaCompliance } = useAuth(); + const { user, isAuthenticated, mfaCompliance, logout } = useAuth(); const [currentOrg, setCurrentOrg] = useState(null); // Use React Query hook for organizations with automatic caching and deduplication const { data: organizations = [], isLoading: orgsLoading } = useOrganizations(); - // Debug logging - console.log('[TopBar] organizations data:', organizations); - console.log('[TopBar] organizations is array:', Array.isArray(organizations)); - // Ensure organizations is always an array (defensive check) const organizationsArray = Array.isArray(organizations) ? organizations : []; @@ -39,8 +35,8 @@ export function TopBar() { } }, [organizationsArray, currentOrg]); - const handleLogout = () => { - navigate("/login"); + const handleLogout = async () => { + await logout(); }; const userInitials = user?.full_name diff --git a/src/lib/api.ts b/src/lib/api.ts index b9a6578..ac4d37d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -167,6 +167,25 @@ export interface LinkedAccountsResponse { unlink_available: boolean; } +export interface PrincipalOption { + id: string; + name: string; + description: string | null; +} + +export interface MyPrincipalsOrg { + org_id: string; + org_name: string; + role: string; + is_admin: boolean; + my_principals: PrincipalOption[]; + all_principals: PrincipalOption[]; // populated for admin/owner only +} + +export interface MyPrincipalsResponse { + orgs: MyPrincipalsOrg[]; +} + export interface OAuthAuthorizeResponse { authorization_url: string; state: string; @@ -392,6 +411,18 @@ export const api = { body: JSON.stringify({ email }), }, false), + activate: (activation_key: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/activate', { + method: 'POST', + body: JSON.stringify({ activation_key }), + }, false), + + resendActivation: (email: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/resend-activation', { + method: 'POST', + body: JSON.stringify({ email }), + }, false), + logout: async (): Promise => { try { await request('/auth/logout', { @@ -417,6 +448,10 @@ export const api = { organizations: (requestConfig?: RequestConfig) => request('/users/me/organizations', {}, true, requestConfig), + // Get the current user's effective principals across all orgs + myPrincipals: (requestConfig?: RequestConfig) => + request('/users/me/principals', {}, true, requestConfig), + // Password change can return 401 for wrong current password - don't clear token changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) => request<{ message: string }>('/users/me/password', { @@ -447,6 +482,19 @@ export const api = { true, requestConfig, ), + + // List users visible to the calling admin + listUsers: (params?: Record, requestConfig?: RequestConfig) => + request<{ users: User[]; count: number; page: number; per_page: number; pages: number }>( + `/admin/users${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig, + ), + + // Get a single user's profile + SSH keys (admin view) + getUser: (userId: string, requestConfig?: RequestConfig) => + request<{ user: User; ssh_keys: SSHKey[] }>(`/admin/users/${userId}`, {}, true, requestConfig), }, totp: { @@ -800,9 +848,8 @@ export const api = { // Link principal to department linkPrincipalToDepartment: (orgId: string, principalId: string, departmentId: string, requestConfig?: RequestConfig) => - request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/departments`, { + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/departments/${departmentId}`, { method: 'POST', - body: JSON.stringify({ department_id: departmentId }), }, true, requestConfig), // Unlink principal from department @@ -811,6 +858,14 @@ export const api = { method: 'DELETE', }, true, requestConfig), + // Get departments linked to a principal + getPrincipalDepartments: (orgId: string, principalId: string, requestConfig?: RequestConfig) => + request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/principals/${principalId}/departments`, {}, true, requestConfig), + + // Get principals linked to a department + getDepartmentPrincipals: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ principals: Principal[]; count: number }>(`/organizations/${orgId}/departments/${deptId}/principals`, {}, true, requestConfig), + // Create invite token createInvite: (orgId: string, email: string, role: string, requestConfig?: RequestConfig) => request<{ invite: OrgInvite }>(`/organizations/${orgId}/invites`, { @@ -840,6 +895,24 @@ export const api = { request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, { method: 'POST', }, true, requestConfig), + + // List Certificate Authorities for an org + getCAs: (orgId: string, requestConfig?: RequestConfig) => + request<{ cas: OrgCA[]; count: number }>(`/organizations/${orgId}/cas`, {}, true, requestConfig), + + // Create a new Certificate Authority for an org + createCA: (orgId: string, data: { name: string; description?: string; ca_type?: 'user' | 'host'; key_type?: 'ed25519' | 'rsa' | 'ecdsa'; default_cert_validity_hours?: number; max_cert_validity_hours?: number }, requestConfig?: RequestConfig) => + request<{ ca: OrgCA }>(`/organizations/${orgId}/cas`, { + method: 'POST', + body: JSON.stringify(data), + }, true, requestConfig), + + // Update CA configuration + updateCA: (orgId: string, caId: string, data: { default_cert_validity_hours?: number; max_cert_validity_hours?: number }, requestConfig?: RequestConfig) => + request<{ ca: OrgCA }>(`/organizations/${orgId}/cas/${caId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), }, invites: { @@ -862,6 +935,93 @@ export const api = { false, ), }, + + ssh: { + // List all SSH keys for the current user + listKeys: (requestConfig?: RequestConfig) => + request('/ssh/keys', {}, true, requestConfig), + + // Add a new SSH public key + addKey: (public_key: string, description?: string, requestConfig?: RequestConfig) => + request('/ssh/keys', { + method: 'POST', + body: JSON.stringify({ public_key, description }), + }, true, requestConfig), + + // Delete an SSH key + deleteKey: (keyId: string, requestConfig?: RequestConfig) => + request<{ status: string }>(`/ssh/keys/${keyId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Update SSH key description + updateKeyDescription: (keyId: string, description: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/${keyId}/update-description`, { + method: 'PATCH', + body: JSON.stringify({ description }), + }, true, requestConfig), + + // Get a verification challenge for a key + getChallenge: (keyId: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/${keyId}/verify`, {}, true, requestConfig), + + // Submit signature to verify key ownership + verifyKey: (keyId: string, signature: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/${keyId}/verify`, { + method: 'POST', + body: JSON.stringify({ signature, action: 'verify_signature' }), + }, true, requestConfig), + + // Sign a certificate for the given key + signCertificate: (key_id: string, principals?: string[], cert_type?: 'user' | 'host', expiry_hours?: number, requestConfig?: RequestConfig) => + request('/ssh/sign', { + method: 'POST', + body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }), + }, true, requestConfig), + + // List issued certificates for the current user + listCertificates: (requestConfig?: RequestConfig) => + request<{ certificates: SSHCertificate[]; count: number }>('/ssh/certificates', {}, true, requestConfig), + + // Get a single certificate (includes full cert text) + getCertificate: (certId: string, requestConfig?: RequestConfig) => + request(`/ssh/certificates/${certId}`, {}, true, requestConfig), + + // Revoke a certificate + revokeCertificate: (certId: string, reason?: string, requestConfig?: RequestConfig) => + request<{ status: string; cert_id: string; reason: string }>(`/ssh/certificates/${certId}/revoke`, { + method: 'POST', + body: JSON.stringify({ reason }), + }, true, requestConfig), + + // Get the CA public key for the current user's org + getCaPublicKey: (requestConfig?: RequestConfig) => + request<{ public_key: string; fingerprint: string; ca_name: string; source: string }>('/ssh/ca/public-key', {}, true, requestConfig), + + // Add SSH key on behalf of another user (admin) + adminAddKey: (userId: string, public_key: string, description?: string, requestConfig?: RequestConfig) => + request(`/ssh/keys/admin/${userId}`, { + method: 'POST', + body: JSON.stringify({ public_key, description }), + }, true, requestConfig), + + // List CA permissions for a CA + listCaPermissions: (caId: string, requestConfig?: RequestConfig) => + request<{ ca_id: string; permissions: CAPermission[]; open_to_all: boolean }>(`/ssh/ca/${caId}/permissions`, {}, true, requestConfig), + + // Grant a user permission on a CA + addCaPermission: (caId: string, user_id: string, permission: 'sign' | 'admin', requestConfig?: RequestConfig) => + request<{ message: string; permission: CAPermission }>(`/ssh/ca/${caId}/permissions`, { + method: 'POST', + body: JSON.stringify({ user_id, permission }), + }, true, requestConfig), + + // Revoke a user's CA permission + removeCaPermission: (caId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/ssh/ca/${caId}/permissions/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + }, }; // Organization types @@ -990,6 +1150,88 @@ export interface OrgComplianceMember { export { ApiError }; +// SSH Key types +export interface SSHKey { + id: string; + user_id: string; + public_key: string; + description: string | null; + key_type: string | null; + fingerprint: string | null; + verified: boolean; + verified_at: string | null; + created_at: string; + updated_at: string; +} + +export interface SSHKeysResponse { + keys: SSHKey[]; + count: number; +} + +export interface SSHChallengeResponse { + challenge_text: string; + validationText: string; + key_id: string; +} + +export interface SSHVerifyResponse { + verified: boolean; +} + +export interface SSHCertificate { + id: string; + user_id: string; + ssh_key_id: string | null; + certificate: string; + serial: number | null; + key_id: string | null; + cert_type: string; + principals: string[]; + valid_after: string; + valid_before: string; + revoked: boolean; + status: string; + created_at: string; +} + +export interface SSHSignResponse { + certificate: string; + serial: number; + principals: string[]; + valid_after: string; + valid_before: string; + cert_id?: string; +} + +export interface CAPermission { + id: string; + ca_id: string; + user_id: string; + user_email: string | null; + permission: 'sign' | 'admin'; + created_at: string; +} + +export interface OrgCA { + id: string; + organization_id: string | null; + name: string; + description: string | null; + ca_type: 'user' | 'host'; + key_type: string; + public_key: string; + fingerprint: string; + is_active: boolean; + default_cert_validity_hours: number; + max_cert_validity_hours: number; + total_certs: number; + active_certs: number; + revoked_certs: number; + created_at: string; + updated_at: string; +} + // Reusable 403 error handler for API calls // Shows a user-friendly toast message when access is denied export function create403Handler(toastFn: (options: { title: string; description: string; variant: "destructive" }) => void) { diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 1deb5bb..66bdbd4 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,13 +1,20 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/contexts/AuthContext"; const Index = () => { const navigate = useNavigate(); + const { isAuthenticated, isLoading } = useAuth(); useEffect(() => { - // Redirect to login for now - will be replaced with auth check - navigate("/login"); - }, [navigate]); + if (isLoading) return; // Wait for auth check to complete + + if (isAuthenticated) { + navigate("/profile"); + } else { + navigate("/login"); + } + }, [isLoading, isAuthenticated, navigate]); return null; }; diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..9afd20b --- /dev/null +++ b/src/pages/admin/AdminUsersPage.tsx @@ -0,0 +1,376 @@ +import { useState, useCallback, useEffect } from "react"; +import { + Search, + User, + CheckCircle, + XCircle, + Key, + Loader2, + Plus, + ChevronRight, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { api, User as ApiUser, SSHKey, ApiError } from "@/lib/api"; + +function formatDate(d: string | null) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +export default function AdminUsersPage() { + const { toast } = useToast(); + + // User list + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + // Debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(t); + }, [search]); + + // User detail drawer + const [selectedUser, setSelectedUser] = useState(null); + const [userSshKeys, setUserSshKeys] = useState([]); + const [isDrawerLoading, setIsDrawerLoading] = useState(false); + + // Admin add SSH key dialog + const [showAddKey, setShowAddKey] = useState(false); + const [addKeyPublicKey, setAddKeyPublicKey] = useState(""); + const [addKeyDescription, setAddKeyDescription] = useState(""); + const [isAddingKey, setIsAddingKey] = useState(false); + const [addKeyError, setAddKeyError] = useState(null); + + // ── Fetch users ───────────────────────────────────────────────────────────── + const fetchUsers = useCallback(async (q: string, pg: number) => { + setIsLoading(true); + try { + const params: Record = { page: String(pg), per_page: "50" }; + if (q) params.q = q; + const data = await api.admin.listUsers(params); + setUsers(data.users); + setTotal(data.count); + setPages(data.pages); + } catch (err) { + if (err instanceof ApiError && err.code === 403) { + toast({ + variant: "destructive", + title: "Access denied", + description: "Admin or owner role required to view all users.", + }); + } else { + toast({ variant: "destructive", title: "Failed to load users" }); + } + } finally { + setIsLoading(false); + } + }, [toast]); + + useEffect(() => { + setPage(1); + fetchUsers(debouncedSearch, 1); + }, [debouncedSearch, fetchUsers]); + + useEffect(() => { + fetchUsers(debouncedSearch, page); + }, [page]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Open user drawer ───────────────────────────────────────────────────────── + const openUserDrawer = async (user: ApiUser) => { + setSelectedUser(user); + setUserSshKeys([]); + setIsDrawerLoading(true); + try { + const data = await api.admin.getUser(user.id); + setUserSshKeys(data.ssh_keys); + } catch { + // Non-fatal — drawer still shows basic user info + } finally { + setIsDrawerLoading(false); + } + }; + + // ── Admin add SSH key ──────────────────────────────────────────────────────── + const handleAddKey = async () => { + if (!selectedUser) return; + setAddKeyError(null); + if (!addKeyPublicKey.trim()) { + setAddKeyError("Public key is required"); + return; + } + setIsAddingKey(true); + try { + const key = await api.ssh.adminAddKey(selectedUser.id, addKeyPublicKey.trim(), addKeyDescription.trim() || undefined); + setUserSshKeys((prev) => [...prev, key]); + toast({ title: "SSH key added", description: `Key added for ${selectedUser.email}` }); + setShowAddKey(false); + setAddKeyPublicKey(""); + setAddKeyDescription(""); + } catch (err) { + setAddKeyError(err instanceof ApiError ? err.message : "Failed to add key"); + } finally { + setIsAddingKey(false); + } + }; + + // ────────────────────────────────────────────────────────────────────────────── + return ( +
+
+

User Management

+

+ View and manage users across your organizations +

+
+ + {/* Search bar */} +
+ + setSearch(e.target.value)} + /> +
+ + + + + + Users + {!isLoading && {total}} + + Click a user to view details and manage their SSH keys + + + {isLoading ? ( +
+ +
+ ) : users.length === 0 ? ( +
+ +

{debouncedSearch ? "No users match your search" : "No users found"}

+
+ ) : ( +
+ {users.map((user) => ( + + ))} +
+ )} + + {/* Pagination */} + {pages > 1 && ( +
+

+ Page {page} of {pages} · {total} total +

+
+ + +
+
+ )} +
+
+ + {/* ── User detail drawer ─────────────────────────────────────────────────── */} + { if (!open) setSelectedUser(null); }}> + + {selectedUser && ( + <> + + + + {selectedUser.full_name || selectedUser.email} + + {selectedUser.email} + + + {/* Basic info */} +
+
+ Joined + {formatDate(selectedUser.created_at)} + Activated + + {(selectedUser as ApiUser & { activated?: boolean }).activated === false ? ( + <> No + ) : ( + <> Yes + )} + +
+
+ + {/* SSH Keys section */} +
+
+

+ + SSH Keys +

+ +
+ + {isDrawerLoading ? ( +
+ +
+ ) : userSshKeys.length === 0 ? ( +
+ No SSH keys registered +
+ ) : ( +
+ {userSshKeys.map((k) => ( +
+
+ {k.description || No description} + {k.verified ? ( + + Verified + + ) : ( + + Unverified + + )} +
+
+ {k.fingerprint ?? k.public_key.slice(0, 64) + "…"} +
+
+ Added {formatDate(k.created_at)} +
+
+ ))} +
+ )} +
+ + )} +
+
+ + {/* ── Admin add SSH key dialog ───────────────────────────────────────────── */} + { setShowAddKey(open); setAddKeyError(null); }}> + + + Add SSH Key for {selectedUser?.email} + + Add an SSH public key on behalf of this user (admin action). + + +
+ {addKeyError && ( +
{addKeyError}
+ )} +
+ +