Feat(Fix): SSH Keys-Expiry+Log; Department+Principal Link; CA Keys mgmt;
- Fix Login nav to /profile or /
This commit is contained in:
+19
-2
@@ -19,12 +19,14 @@ import InviteAcceptPage from "@/pages/auth/InviteAcceptPage";
|
|||||||
import OIDCConsentPage from "@/pages/auth/OIDCConsentPage";
|
import OIDCConsentPage from "@/pages/auth/OIDCConsentPage";
|
||||||
import OIDCErrorPage from "@/pages/auth/OIDCErrorPage";
|
import OIDCErrorPage from "@/pages/auth/OIDCErrorPage";
|
||||||
import OAuthCallbackPage from "@/pages/auth/OAuthCallbackPage";
|
import OAuthCallbackPage from "@/pages/auth/OAuthCallbackPage";
|
||||||
|
import ActivatePage from "@/pages/auth/ActivatePage";
|
||||||
|
|
||||||
// User pages
|
// User pages
|
||||||
import ProfilePage from "@/pages/user/ProfilePage";
|
import ProfilePage from "@/pages/user/ProfilePage";
|
||||||
import SecurityPage from "@/pages/user/SecurityPage";
|
import SecurityPage from "@/pages/user/SecurityPage";
|
||||||
import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage";
|
import LinkedAccountsPage from "@/pages/user/LinkedAccountsPage";
|
||||||
import ActivityPage from "@/pages/user/ActivityPage";
|
import ActivityPage from "@/pages/user/ActivityPage";
|
||||||
|
import SSHKeysPage from "@/pages/user/SSHKeysPage";
|
||||||
|
|
||||||
// Organization pages
|
// Organization pages
|
||||||
import OrgOverviewPage from "@/pages/org/OrgOverviewPage";
|
import OrgOverviewPage from "@/pages/org/OrgOverviewPage";
|
||||||
@@ -33,9 +35,11 @@ import PoliciesPage from "@/pages/org/PoliciesPage";
|
|||||||
import CompliancePage from "@/pages/org/CompliancePage";
|
import CompliancePage from "@/pages/org/CompliancePage";
|
||||||
import OrgAuditPage from "@/pages/org/OrgAuditPage";
|
import OrgAuditPage from "@/pages/org/OrgAuditPage";
|
||||||
import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
|
import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
|
||||||
|
import CAsPage from "@/pages/org/CAsPage";
|
||||||
import DepartmentsPage from "@/pages/org/DepartmentsPage";
|
import DepartmentsPage from "@/pages/org/DepartmentsPage";
|
||||||
import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
||||||
import SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
import SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
||||||
|
import AdminUsersPage from "@/pages/admin/AdminUsersPage";
|
||||||
|
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
import ApiDevTools from "@/components/dev/ApiDevTools";
|
import ApiDevTools from "@/components/dev/ApiDevTools";
|
||||||
@@ -68,7 +72,16 @@ const App = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Separate component so AuthProvider can use useNavigate
|
// 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 <Navigate to="/profile" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -79,7 +92,7 @@ function AppRoutes() {
|
|||||||
|
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<GuestRoute><LoginPage /></GuestRoute>} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
@@ -88,6 +101,7 @@ function AppRoutes() {
|
|||||||
<Route path="/consent" element={<OIDCConsentPage />} />
|
<Route path="/consent" element={<OIDCConsentPage />} />
|
||||||
<Route path="/error" element={<OIDCErrorPage />} />
|
<Route path="/error" element={<OIDCErrorPage />} />
|
||||||
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
|
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
|
||||||
|
<Route path="/activate" element={<ActivatePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Protected routes - handles auth and MFA enforcement */}
|
{/* Protected routes - handles auth and MFA enforcement */}
|
||||||
@@ -97,6 +111,7 @@ function AppRoutes() {
|
|||||||
<Route path="/security" element={<SecurityPage />} />
|
<Route path="/security" element={<SecurityPage />} />
|
||||||
<Route path="/linked-accounts" element={<LinkedAccountsPage />} />
|
<Route path="/linked-accounts" element={<LinkedAccountsPage />} />
|
||||||
<Route path="/activity" element={<ActivityPage />} />
|
<Route path="/activity" element={<ActivityPage />} />
|
||||||
|
<Route path="/ssh-keys" element={<SSHKeysPage />} />
|
||||||
|
|
||||||
{/* Organization routes */}
|
{/* Organization routes */}
|
||||||
<Route path="/org" element={<OrgOverviewPage />} />
|
<Route path="/org" element={<OrgOverviewPage />} />
|
||||||
@@ -107,9 +122,11 @@ function AppRoutes() {
|
|||||||
<Route path="/org/policies/compliance" element={<CompliancePage />} />
|
<Route path="/org/policies/compliance" element={<CompliancePage />} />
|
||||||
<Route path="/org/audit" element={<OrgAuditPage />} />
|
<Route path="/org/audit" element={<OrgAuditPage />} />
|
||||||
<Route path="/org/clients" element={<OIDCClientsPage />} />
|
<Route path="/org/clients" element={<OIDCClientsPage />} />
|
||||||
|
<Route path="/org/cas" element={<CAsPage />} />
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
<Route path="/admin/audit" element={<SystemAuditPage />} />
|
<Route path="/admin/audit" element={<SystemAuditPage />} />
|
||||||
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch-all */}
|
{/* Catch-all */}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Terminal,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
@@ -33,6 +35,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const userNavItems = [
|
const userNavItems = [
|
||||||
{ title: "Profile", url: "/profile", icon: User },
|
{ title: "Profile", url: "/profile", icon: User },
|
||||||
{ title: "Security", url: "/security", icon: Shield },
|
{ title: "Security", url: "/security", icon: Shield },
|
||||||
|
{ title: "SSH Keys", url: "/ssh-keys", icon: Terminal },
|
||||||
{ title: "Linked Accounts", url: "/linked-accounts", icon: Link2 },
|
{ title: "Linked Accounts", url: "/linked-accounts", icon: Link2 },
|
||||||
{ title: "Activity", url: "/activity", icon: Activity },
|
{ title: "Activity", url: "/activity", icon: Activity },
|
||||||
];
|
];
|
||||||
@@ -48,6 +51,8 @@ const orgNavItems = [
|
|||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ title: "OIDC Clients", url: "/org/clients", icon: Key },
|
{ 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 },
|
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,12 @@ import { ComplianceBanner } from "@/components/auth/ComplianceBanner";
|
|||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAuthenticated, mfaCompliance } = useAuth();
|
const { user, isAuthenticated, mfaCompliance, logout } = useAuth();
|
||||||
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
|
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
|
||||||
|
|
||||||
// Use React Query hook for organizations with automatic caching and deduplication
|
// Use React Query hook for organizations with automatic caching and deduplication
|
||||||
const { data: organizations = [], isLoading: orgsLoading } = useOrganizations();
|
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)
|
// Ensure organizations is always an array (defensive check)
|
||||||
const organizationsArray = Array.isArray(organizations) ? organizations : [];
|
const organizationsArray = Array.isArray(organizations) ? organizations : [];
|
||||||
|
|
||||||
@@ -39,8 +35,8 @@ export function TopBar() {
|
|||||||
}
|
}
|
||||||
}, [organizationsArray, currentOrg]);
|
}, [organizationsArray, currentOrg]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
navigate("/login");
|
await logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
const userInitials = user?.full_name
|
const userInitials = user?.full_name
|
||||||
|
|||||||
+244
-2
@@ -167,6 +167,25 @@ export interface LinkedAccountsResponse {
|
|||||||
unlink_available: boolean;
|
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 {
|
export interface OAuthAuthorizeResponse {
|
||||||
authorization_url: string;
|
authorization_url: string;
|
||||||
state: string;
|
state: string;
|
||||||
@@ -392,6 +411,18 @@ export const api = {
|
|||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
}, false),
|
}, 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<void> => {
|
logout: async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await request<void>('/auth/logout', {
|
await request<void>('/auth/logout', {
|
||||||
@@ -417,6 +448,10 @@ export const api = {
|
|||||||
organizations: (requestConfig?: RequestConfig) =>
|
organizations: (requestConfig?: RequestConfig) =>
|
||||||
request<OrganizationsResponse>('/users/me/organizations', {}, true, requestConfig),
|
request<OrganizationsResponse>('/users/me/organizations', {}, true, requestConfig),
|
||||||
|
|
||||||
|
// Get the current user's effective principals across all orgs
|
||||||
|
myPrincipals: (requestConfig?: RequestConfig) =>
|
||||||
|
request<MyPrincipalsResponse>('/users/me/principals', {}, true, requestConfig),
|
||||||
|
|
||||||
// Password change can return 401 for wrong current password - don't clear token
|
// Password change can return 401 for wrong current password - don't clear token
|
||||||
changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) =>
|
changePassword: (currentPassword: string, newPassword: string, newPasswordConfirm: string) =>
|
||||||
request<{ message: string }>('/users/me/password', {
|
request<{ message: string }>('/users/me/password', {
|
||||||
@@ -447,6 +482,19 @@ export const api = {
|
|||||||
true,
|
true,
|
||||||
requestConfig,
|
requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// List users visible to the calling admin
|
||||||
|
listUsers: (params?: Record<string, string>, 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: {
|
totp: {
|
||||||
@@ -800,9 +848,8 @@ export const api = {
|
|||||||
|
|
||||||
// Link principal to department
|
// Link principal to department
|
||||||
linkPrincipalToDepartment: (orgId: string, principalId: string, departmentId: string, requestConfig?: RequestConfig) =>
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify({ department_id: departmentId }),
|
|
||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
// Unlink principal from department
|
// Unlink principal from department
|
||||||
@@ -811,6 +858,14 @@ export const api = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}, true, requestConfig),
|
}, 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
|
// Create invite token
|
||||||
createInvite: (orgId: string, email: string, role: string, requestConfig?: RequestConfig) =>
|
createInvite: (orgId: string, email: string, role: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ invite: OrgInvite }>(`/organizations/${orgId}/invites`, {
|
request<{ invite: OrgInvite }>(`/organizations/${orgId}/invites`, {
|
||||||
@@ -840,6 +895,24 @@ export const api = {
|
|||||||
request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, {
|
request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}, true, requestConfig),
|
}, 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: {
|
invites: {
|
||||||
@@ -862,6 +935,93 @@ export const api = {
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ssh: {
|
||||||
|
// List all SSH keys for the current user
|
||||||
|
listKeys: (requestConfig?: RequestConfig) =>
|
||||||
|
request<SSHKeysResponse>('/ssh/keys', {}, true, requestConfig),
|
||||||
|
|
||||||
|
// Add a new SSH public key
|
||||||
|
addKey: (public_key: string, description?: string, requestConfig?: RequestConfig) =>
|
||||||
|
request<SSHKey>('/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<SSHKey>(`/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<SSHChallengeResponse>(`/ssh/keys/${keyId}/verify`, {}, true, requestConfig),
|
||||||
|
|
||||||
|
// Submit signature to verify key ownership
|
||||||
|
verifyKey: (keyId: string, signature: string, requestConfig?: RequestConfig) =>
|
||||||
|
request<SSHVerifyResponse>(`/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<SSHSignResponse>('/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<SSHCertificate>(`/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<SSHKey>(`/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
|
// Organization types
|
||||||
@@ -990,6 +1150,88 @@ export interface OrgComplianceMember {
|
|||||||
|
|
||||||
export { ApiError };
|
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
|
// Reusable 403 error handler for API calls
|
||||||
// Shows a user-friendly toast message when access is denied
|
// Shows a user-friendly toast message when access is denied
|
||||||
export function create403Handler(toastFn: (options: { title: string; description: string; variant: "destructive" }) => void) {
|
export function create403Handler(toastFn: (options: { title: string; description: string; variant: "destructive" }) => void) {
|
||||||
|
|||||||
+10
-3
@@ -1,13 +1,20 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect to login for now - will be replaced with auth check
|
if (isLoading) return; // Wait for auth check to complete
|
||||||
navigate("/login");
|
|
||||||
}, [navigate]);
|
if (isAuthenticated) {
|
||||||
|
navigate("/profile");
|
||||||
|
} else {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, navigate]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<ApiUser[]>([]);
|
||||||
|
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<ApiUser | null>(null);
|
||||||
|
const [userSshKeys, setUserSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
// ── Fetch users ─────────────────────────────────────────────────────────────
|
||||||
|
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { 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 (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">User Management</h1>
|
||||||
|
<p className="page-description">
|
||||||
|
View and manage users across your organizations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Search by name or email…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Users
|
||||||
|
{!isLoading && <Badge variant="secondary" className="ml-1">{total}</Badge>}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Click a user to view details and manage their SSH keys</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<User className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||||||
|
<p className="text-sm">{debouncedSearch ? "No users match your search" : "No users found"}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{users.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left"
|
||||||
|
onClick={() => openUserDrawer(user)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{user.full_name || user.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{(user as ApiUser & { activated?: boolean }).activated === false && (
|
||||||
|
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
||||||
|
Not activated
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Page {page} of {pages} · {total} total
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── User detail drawer ─────────────────────────────────────────────────── */}
|
||||||
|
<Sheet open={!!selectedUser} onOpenChange={(open) => { if (!open) setSelectedUser(null); }}>
|
||||||
|
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||||
|
{selectedUser && (
|
||||||
|
<>
|
||||||
|
<SheetHeader className="mb-4">
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
{selectedUser.full_name || selectedUser.email}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>{selectedUser.email}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* Basic info */}
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Joined</span>
|
||||||
|
<span>{formatDate(selectedUser.created_at)}</span>
|
||||||
|
<span className="text-muted-foreground">Activated</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{(selectedUser as ApiUser & { activated?: boolean }).activated === false ? (
|
||||||
|
<><XCircle className="w-4 h-4 text-amber-500" /> No</>
|
||||||
|
) : (
|
||||||
|
<><CheckCircle className="w-4 h-4 text-green-500" /> Yes</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSH Keys section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
SSH Keys
|
||||||
|
</h3>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setShowAddKey(true)}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Add key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDrawerLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : userSshKeys.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-muted-foreground text-sm">
|
||||||
|
No SSH keys registered
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{userSshKeys.map((k) => (
|
||||||
|
<div key={k.id} className="p-3 border rounded-lg text-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium">{k.description || <em className="text-muted-foreground">No description</em>}</span>
|
||||||
|
{k.verified ? (
|
||||||
|
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
|
||||||
|
<CheckCircle className="w-3 h-3 mr-1" />Verified
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
||||||
|
Unverified
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
{k.fingerprint ?? k.public_key.slice(0, 64) + "…"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
Added {formatDate(k.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* ── Admin add SSH key dialog ───────────────────────────────────────────── */}
|
||||||
|
<Dialog open={showAddKey} onOpenChange={(open) => { setShowAddKey(open); setAddKeyError(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add SSH Key for {selectedUser?.email}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add an SSH public key on behalf of this user (admin action).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{addKeyError && (
|
||||||
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{addKeyError}</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Public key</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="ssh-ed25519 AAAA..."
|
||||||
|
value={addKeyPublicKey}
|
||||||
|
onChange={(e) => setAddKeyPublicKey(e.target.value)}
|
||||||
|
className="font-mono text-xs min-h-[80px]"
|
||||||
|
disabled={isAddingKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description <span className="text-muted-foreground">(optional)</span></Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Laptop key"
|
||||||
|
value={addKeyDescription}
|
||||||
|
onChange={(e) => setAddKeyDescription(e.target.value)}
|
||||||
|
disabled={isAddingKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowAddKey(false)} disabled={isAddingKey}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddKey} disabled={isAddingKey || !addKeyPublicKey.trim()}>
|
||||||
|
{isAddingKey && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Add key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
import { CheckCircle, XCircle, Loader2, Mail } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
||||||
|
import { api, ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
type Status = "loading" | "success" | "error" | "missing";
|
||||||
|
|
||||||
|
export default function ActivatePage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
|
const [message, setMessage] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const code = searchParams.get("code") || searchParams.get("activation_key") || searchParams.get("key");
|
||||||
|
if (!code) {
|
||||||
|
setStatus("missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.auth
|
||||||
|
.activate(code)
|
||||||
|
.then(() => {
|
||||||
|
setStatus("success");
|
||||||
|
setMessage("Your account has been activated. You can now sign in.");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const msg =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: "Activation failed. The link may have expired or already been used.";
|
||||||
|
setMessage(msg);
|
||||||
|
setStatus("error");
|
||||||
|
});
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<GatehouseLogo size="md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-8 pb-8 px-6 flex flex-col items-center gap-4 text-center">
|
||||||
|
{status === "loading" && (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">Activating your account…</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "success" && (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||||
|
<h1 className="text-xl font-semibold">Account Activated</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
|
<Button className="w-full" onClick={() => navigate("/login")}>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<>
|
||||||
|
<XCircle className="w-12 h-12 text-destructive" />
|
||||||
|
<h1 className="text-xl font-semibold">Activation Failed</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => navigate("/login")}>
|
||||||
|
Back to sign in
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "missing" && (
|
||||||
|
<>
|
||||||
|
<Mail className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h1 className="text-xl font-semibold">Invalid Activation Link</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No activation code was found in this link. Please check your email and use the
|
||||||
|
link provided.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => navigate("/login")}>
|
||||||
|
Back to sign in
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
|
Copy,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||||
|
import { api, OrgCA, CAPermission, ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
function CopyButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
toast({ title: "Copied to clipboard" });
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast({ variant: "destructive", title: "Copy failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 flex-shrink-0" onClick={handleCopy}>
|
||||||
|
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string | null) {
|
||||||
|
if (!d) return "—";
|
||||||
|
return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PermissionsCardProps {
|
||||||
|
ca: OrgCA;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionsCard({ ca }: PermissionsCardProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [perms, setPerms] = useState<CAPermission[]>([]);
|
||||||
|
const [openToAll, setOpenToAll] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [addEmail, setAddEmail] = useState("");
|
||||||
|
const [addPermission, setAddPermission] = useState<"sign" | "admin">("sign");
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
const [removingUserId, setRemovingUserId] = useState<string | null>(null);
|
||||||
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
|
|
||||||
|
const fetchPerms = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.ssh.listCaPermissions(ca.id);
|
||||||
|
setPerms(data.permissions);
|
||||||
|
setOpenToAll(data.open_to_all);
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [ca.id]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPerms(); }, [fetchPerms]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
setAddError(null);
|
||||||
|
if (!addEmail.trim()) { setAddError("Email is required"); return; }
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
// Resolve user_id from email via org members search
|
||||||
|
// We pass the email as user_id — the backend expects a user UUID.
|
||||||
|
// To keep it simple, we pass the email; if the backend doesn't support
|
||||||
|
// lookup by email, the admin must use the user UUID directly.
|
||||||
|
await api.ssh.addCaPermission(ca.id, addEmail.trim(), addPermission);
|
||||||
|
toast({ title: "Permission granted" });
|
||||||
|
setShowAdd(false);
|
||||||
|
setAddEmail("");
|
||||||
|
fetchPerms();
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(err instanceof ApiError ? err.message : "Failed to add permission");
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
if (!removingUserId) return;
|
||||||
|
setIsRemoving(true);
|
||||||
|
try {
|
||||||
|
await api.ssh.removeCaPermission(ca.id, removingUserId);
|
||||||
|
setPerms((prev) => prev.filter((p) => p.user_id !== removingUserId));
|
||||||
|
toast({ title: "Permission revoked" });
|
||||||
|
} catch (err) {
|
||||||
|
toast({ variant: "destructive", title: "Failed to revoke permission", description: err instanceof ApiError ? err.message : "" });
|
||||||
|
} finally {
|
||||||
|
setIsRemoving(false);
|
||||||
|
setRemovingUserId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
Access Control
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs mt-0.5">
|
||||||
|
{openToAll
|
||||||
|
? "Open to all org members — add users below to restrict access"
|
||||||
|
: "Restricted — only listed users may sign certificates"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setShowAdd(true)}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : perms.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||||
|
<Lock className="w-6 h-6 mx-auto mb-2 opacity-40" />
|
||||||
|
{openToAll ? "No restrictions — all org members can sign" : "No users granted access"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{perms.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between p-2 border rounded-lg text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{p.user_email ?? p.user_id}</p>
|
||||||
|
<Badge variant="secondary" className="text-xs mt-0.5">{p.permission}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setRemovingUserId(p.user_id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Add permission dialog */}
|
||||||
|
<Dialog open={showAdd} onOpenChange={(o) => { setShowAdd(o); setAddError(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Grant CA Access</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the user ID (UUID) to grant permission on <strong>{ca.name}</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{addError && <div className="p-2 rounded bg-destructive/10 text-destructive text-xs">{addError}</div>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">User ID</Label>
|
||||||
|
<Input placeholder="uuid..." value={addEmail} onChange={(e) => setAddEmail(e.target.value)} disabled={isAdding} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Permission</Label>
|
||||||
|
<Select value={addPermission} onValueChange={(v) => setAddPermission(v as "sign" | "admin")}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sign">sign — can request certificates</SelectItem>
|
||||||
|
<SelectItem value="admin">admin — can sign + manage CA</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowAdd(false)} disabled={isAdding}>Cancel</Button>
|
||||||
|
<Button onClick={handleAdd} disabled={isAdding || !addEmail.trim()}>
|
||||||
|
{isAdding && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Grant
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Confirm revoke */}
|
||||||
|
<AlertDialog open={!!removingUserId} onOpenChange={() => setRemovingUserId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Revoke access?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove the user's permission to sign certificates with this CA.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isRemoving}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isRemoving}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isRemoving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Revoke
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CA Detail Card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CADetailCardProps {
|
||||||
|
ca: OrgCA;
|
||||||
|
onEdit: (ca: OrgCA) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CADetailCard({ ca, onEdit }: CADetailCardProps) {
|
||||||
|
const isUser = ca.ca_type === "user";
|
||||||
|
const sshConfig = isUser
|
||||||
|
? `# /etc/ssh/sshd_config:\nTrustedUserCAKeys /etc/ssh/trusted_user_ca_keys\n\n# Add public key:\necho '${ca.public_key.trim()}' \\\n >> /etc/ssh/trusted_user_ca_keys`
|
||||||
|
: `# /etc/ssh/sshd_config:\nHostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub\n\n# Add to known_hosts (clients):\n@cert-authority * ${ca.public_key.trim()}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
{isUser ? <User className="w-4 h-4" /> : <Server className="w-4 h-4" />}
|
||||||
|
{ca.name}
|
||||||
|
{ca.is_active ? (
|
||||||
|
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-xs">Inactive</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
{ca.description && (
|
||||||
|
<CardDescription className="mt-1">{ca.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs font-mono">{ca.key_type}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
|
<p className="text-lg font-semibold">{ca.active_certs}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Active certs</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
|
<p className="text-lg font-semibold">{ca.total_certs}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total issued</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
|
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Default validity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fingerprint */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Fingerprint</p>
|
||||||
|
<code className="text-xs font-mono bg-muted px-2 py-1 rounded break-all">{ca.fingerprint}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public key */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Public key</p>
|
||||||
|
<CopyButton text={ca.public_key} />
|
||||||
|
</div>
|
||||||
|
<Textarea readOnly value={ca.public_key} className="font-mono text-xs min-h-[60px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup instructions */}
|
||||||
|
<div className="rounded-lg bg-muted p-3">
|
||||||
|
<p className="text-xs font-semibold flex items-center gap-1 mb-1">
|
||||||
|
<Terminal className="w-3 h-3" />
|
||||||
|
{isUser ? "Add to SSH servers (sshd_config)" : "Host certificate setup"}
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{sshConfig}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">Created {formatDate(ca.created_at)}</p>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onEdit(ca)} className="w-full">
|
||||||
|
<Settings className="w-3 h-3 mr-2" />
|
||||||
|
Edit Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Permissions — user CAs only */}
|
||||||
|
{isUser && <PermissionsCard ca={ca} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CA Section (one per type) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CASectionProps {
|
||||||
|
caType: "user" | "host";
|
||||||
|
ca: OrgCA | null;
|
||||||
|
onCreateClick: (caType: "user" | "host") => void;
|
||||||
|
onEdit: (ca: OrgCA) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CASection({ caType, ca, onCreateClick, onEdit }: CASectionProps) {
|
||||||
|
const isUser = caType === "user";
|
||||||
|
const title = isUser ? "User Signing Key" : "Host Signing Key";
|
||||||
|
const subtitle = isUser
|
||||||
|
? "Signs SSH user certificates so users can authenticate to servers."
|
||||||
|
: "Signs SSH host certificates so clients can verify server identity.";
|
||||||
|
const Icon = isUser ? User : Server;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
|
{ca ? (
|
||||||
|
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Configured</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-xs flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
Not configured
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ca ? (
|
||||||
|
<CADetailCard ca={ca} onEdit={onEdit} />
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center py-10 text-muted-foreground">
|
||||||
|
<ShieldAlert className="w-10 h-10 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm font-medium mb-1">No {title} configured</p>
|
||||||
|
<p className="text-xs text-center mb-4 max-w-sm">{subtitle}</p>
|
||||||
|
<Button onClick={() => onCreateClick(caType)} size="sm" variant="outline">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Generate {title}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CAsPage() {
|
||||||
|
const params = useParams<{ orgId?: string }>();
|
||||||
|
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||||
|
const orgId = params.orgId || fallbackOrgId;
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [cas, setCAs] = useState<OrgCA[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Create CA dialog
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [createCaType, setCreateCaType] = useState<"user" | "host">("user");
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
key_type: "ed25519" as "ed25519" | "rsa" | "ecdsa",
|
||||||
|
default_cert_validity_hours: 8,
|
||||||
|
max_cert_validity_hours: 720,
|
||||||
|
});
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Edit CA dialog
|
||||||
|
const [editingCA, setEditingCA] = useState<OrgCA | null>(null);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editFormData, setEditFormData] = useState({
|
||||||
|
default_cert_validity_hours: 1,
|
||||||
|
max_cert_validity_hours: 24,
|
||||||
|
});
|
||||||
|
const [isEditSaving, setIsEditSaving] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orgId) { setIsLoading(false); return; }
|
||||||
|
(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.organizations.getCAs(orgId);
|
||||||
|
setCAs(data.cas);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.code === 403) {
|
||||||
|
toast({ variant: "destructive", title: "Access denied", description: "Admin or owner role required." });
|
||||||
|
} else {
|
||||||
|
toast({ variant: "destructive", title: "Failed to load CAs" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [orgId, toast]);
|
||||||
|
|
||||||
|
const userCA = cas.find((c) => c.ca_type === "user") ?? null;
|
||||||
|
const hostCA = cas.find((c) => c.ca_type === "host") ?? null;
|
||||||
|
|
||||||
|
const handleOpenCreate = (caType: "user" | "host") => {
|
||||||
|
setCreateCaType(caType);
|
||||||
|
setCreateForm({
|
||||||
|
name: caType === "user" ? "User CA" : "Host CA",
|
||||||
|
description: "",
|
||||||
|
key_type: "ed25519",
|
||||||
|
default_cert_validity_hours: caType === "user" ? 8 : 720,
|
||||||
|
max_cert_validity_hours: caType === "user" ? 720 : 8760,
|
||||||
|
});
|
||||||
|
setCreateError(null);
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCA = async () => {
|
||||||
|
if (!orgId) return;
|
||||||
|
if (!createForm.name.trim()) { setCreateError("Name is required"); return; }
|
||||||
|
if (createForm.default_cert_validity_hours <= 0 || createForm.max_cert_validity_hours <= 0) {
|
||||||
|
setCreateError("Validity hours must be greater than 0"); return;
|
||||||
|
}
|
||||||
|
if (createForm.default_cert_validity_hours > createForm.max_cert_validity_hours) {
|
||||||
|
setCreateError("Default validity must be ≤ maximum validity"); return;
|
||||||
|
}
|
||||||
|
setIsCreating(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.organizations.createCA(orgId, {
|
||||||
|
name: createForm.name.trim(),
|
||||||
|
description: createForm.description.trim() || undefined,
|
||||||
|
ca_type: createCaType,
|
||||||
|
key_type: createForm.key_type,
|
||||||
|
default_cert_validity_hours: createForm.default_cert_validity_hours,
|
||||||
|
max_cert_validity_hours: createForm.max_cert_validity_hours,
|
||||||
|
});
|
||||||
|
setCAs((prev) => [...prev, result.ca]);
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
toast({
|
||||||
|
title: `${createCaType === "user" ? "User" : "Host"} CA created`,
|
||||||
|
description: result.ca.name,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setCreateError(err.message);
|
||||||
|
} else {
|
||||||
|
setCreateError("Failed to create CA — please try again");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCA = (ca: OrgCA) => {
|
||||||
|
setEditingCA(ca);
|
||||||
|
setEditFormData({
|
||||||
|
default_cert_validity_hours: ca.default_cert_validity_hours,
|
||||||
|
max_cert_validity_hours: ca.max_cert_validity_hours,
|
||||||
|
});
|
||||||
|
setEditError(null);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCA = async () => {
|
||||||
|
if (!orgId || !editingCA) return;
|
||||||
|
if (editFormData.default_cert_validity_hours <= 0 || editFormData.max_cert_validity_hours <= 0) {
|
||||||
|
setEditError("Validity hours must be greater than 0"); return;
|
||||||
|
}
|
||||||
|
if (editFormData.default_cert_validity_hours > editFormData.max_cert_validity_hours) {
|
||||||
|
setEditError("Default validity must be less than or equal to maximum validity"); return;
|
||||||
|
}
|
||||||
|
setIsEditSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await api.organizations.updateCA(orgId, editingCA.id, editFormData);
|
||||||
|
setCAs(cas.map((ca) => (ca.id === editingCA.id ? updated.ca : ca)));
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
setEditingCA(null);
|
||||||
|
toast({ title: "CA configuration updated" });
|
||||||
|
} catch (err) {
|
||||||
|
setEditError(err instanceof ApiError ? err.message : "Failed to update CA");
|
||||||
|
} finally {
|
||||||
|
setIsEditSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Certificate Authorities</h1>
|
||||||
|
<p className="page-description">
|
||||||
|
Manage your organization's SSH certificate authorities and access controls
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<CASection caType="user" ca={userCA} onCreateClick={handleOpenCreate} onEdit={handleEditCA} />
|
||||||
|
<div className="border-t" />
|
||||||
|
<CASection caType="host" ca={hostCA} onCreateClick={handleOpenCreate} onEdit={handleEditCA} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Edit CA Dialog ── */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={(open) => { setIsEditDialogOpen(open); if (!open) setEditError(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit CA Configuration</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update certificate validity settings for <strong>{editingCA?.name}</strong>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{editError && (
|
||||||
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
{editError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="default-validity">Default Certificate Validity (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id="default-validity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={editFormData.default_cert_validity_hours}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, default_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||||
|
disabled={isEditSaving}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Default validity period when issuing new certificates</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-validity">Maximum Certificate Validity (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id="max-validity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={editFormData.max_cert_validity_hours}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, max_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||||
|
disabled={isEditSaving}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Maximum allowed validity period for any certificate from this CA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isEditSaving}>Cancel</Button>
|
||||||
|
<Button onClick={handleSaveCA} disabled={isEditSaving}>
|
||||||
|
{isEditSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Create CA Dialog ── */}
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={(open) => { setIsCreateOpen(open); if (!open) setCreateError(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{createCaType === "user" ? <User className="w-5 h-5" /> : <Server className="w-5 h-5" />}
|
||||||
|
Generate {createCaType === "user" ? "User" : "Host"} Signing Key
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{createCaType === "user"
|
||||||
|
? "Creates a key pair for signing SSH user certificates. The private key is stored securely and never exposed."
|
||||||
|
: "Creates a key pair for signing SSH host certificates, allowing clients to verify server identity."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{createError && (
|
||||||
|
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{createError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ca-name">Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="ca-name"
|
||||||
|
placeholder={createCaType === "user" ? "User CA" : "Host CA"}
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ca-description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="ca-description"
|
||||||
|
placeholder="Optional description"
|
||||||
|
value={createForm.description}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ca-key-type">Key Algorithm</Label>
|
||||||
|
<Select
|
||||||
|
value={createForm.key_type}
|
||||||
|
onValueChange={(v) => setCreateForm({ ...createForm, key_type: v as "ed25519" | "rsa" | "ecdsa" })}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="ca-key-type"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ed25519">Ed25519 (recommended)</SelectItem>
|
||||||
|
<SelectItem value="ecdsa">ECDSA (P-521)</SelectItem>
|
||||||
|
<SelectItem value="rsa">RSA-4096</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ca-default-validity">Default validity (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id="ca-default-validity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={createForm.default_cert_validity_hours}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, default_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ca-max-validity">Max validity (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id="ca-max-validity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={createForm.max_cert_validity_hours}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, max_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreating}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreateCA} disabled={isCreating}>
|
||||||
|
{isCreating ? (
|
||||||
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Generating key…</>
|
||||||
|
) : (
|
||||||
|
<><Shield className="w-4 h-4 mr-2" />Generate Key</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2 } from "lucide-react";
|
import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, X } from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -22,16 +21,20 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api, Department } from "@/lib/api";
|
import { api, Department, Principal } from "@/lib/api";
|
||||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function DepartmentsPage() {
|
export default function DepartmentsPage() {
|
||||||
const params = useParams<{ orgId?: string }>();
|
const params = useParams<{ orgId?: string }>();
|
||||||
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||||
const orgId = params.orgId || fallbackOrgId;
|
const orgId = params.orgId || fallbackOrgId;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [departments, setDepartments] = useState<Department[]>([]);
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [linkedPrincipals, setLinkedPrincipals] = useState<Record<string, Principal[]>>({});
|
||||||
|
const [unlinkingKey, setUnlinkingKey] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
@@ -39,34 +42,69 @@ export default function DepartmentsPage() {
|
|||||||
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
||||||
const [formData, setFormData] = useState({ name: "", description: "" });
|
const [formData, setFormData] = useState({ name: "", description: "" });
|
||||||
|
|
||||||
const fetchDepartments = async (currentOrgId: string) => {
|
const fetchLinkedPrincipals = useCallback(async (currentOrgId: string, deptList: Department[]) => {
|
||||||
|
if (!deptList.length) return;
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
deptList.map((dept) =>
|
||||||
|
api.organizations.getDepartmentPrincipals(currentOrgId, dept.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const map: Record<string, Principal[]> = {};
|
||||||
|
deptList.forEach((dept, i) => {
|
||||||
|
const result = results[i];
|
||||||
|
map[dept.id] = result.status === "fulfilled" ? result.value.principals || [] : [];
|
||||||
|
});
|
||||||
|
setLinkedPrincipals(map);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchDepartments = useCallback(async (currentOrgId: string) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await api.organizations.getDepartments(currentOrgId);
|
const response = await api.organizations.getDepartments(currentOrgId);
|
||||||
setDepartments(response.departments || []);
|
const deptList = response.departments || [];
|
||||||
|
setDepartments(deptList);
|
||||||
|
await fetchLinkedPrincipals(currentOrgId, deptList);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch departments:", err);
|
console.error("Failed to fetch departments:", err);
|
||||||
setError("Failed to load departments. Please try again.");
|
setError("Failed to load departments. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [fetchLinkedPrincipals]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setDepartments([]);
|
setDepartments([]);
|
||||||
|
setLinkedPrincipals({});
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchDepartments(orgId);
|
fetchDepartments(orgId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [orgId, fetchDepartments]);
|
||||||
}, [orgId]);
|
|
||||||
|
const handleUnlink = async (deptId: string, principalId: string) => {
|
||||||
|
if (!orgId) return;
|
||||||
|
const key = `${deptId}:${principalId}`;
|
||||||
|
setUnlinkingKey(key);
|
||||||
|
try {
|
||||||
|
await api.organizations.unlinkPrincipalFromDepartment(orgId, principalId, deptId);
|
||||||
|
setLinkedPrincipals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[deptId]: (prev[deptId] || []).filter((p) => p.id !== principalId),
|
||||||
|
}));
|
||||||
|
toast({ title: "Unlinked", description: "Principal removed from department." });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to unlink:", err);
|
||||||
|
toast({ title: "Error", description: "Failed to unlink principal.", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setUnlinkingKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateDepartment = async () => {
|
const handleCreateDepartment = async () => {
|
||||||
if (!orgId || !formData.name.trim()) return;
|
if (!orgId || !formData.name.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.organizations.createDepartment(
|
await api.organizations.createDepartment(
|
||||||
orgId,
|
orgId,
|
||||||
@@ -84,16 +122,11 @@ export default function DepartmentsPage() {
|
|||||||
|
|
||||||
const handleUpdateDepartment = async () => {
|
const handleUpdateDepartment = async () => {
|
||||||
if (!orgId || !editingDept || !formData.name.trim()) return;
|
if (!orgId || !editingDept || !formData.name.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.organizations.updateDepartment(
|
await api.organizations.updateDepartment(orgId, editingDept.id, {
|
||||||
orgId,
|
name: formData.name,
|
||||||
editingDept.id,
|
description: formData.description || undefined,
|
||||||
{
|
});
|
||||||
name: formData.name,
|
|
||||||
description: formData.description || undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setFormData({ name: "", description: "" });
|
setFormData({ name: "", description: "" });
|
||||||
setEditingDept(null);
|
setEditingDept(null);
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
@@ -106,7 +139,6 @@ export default function DepartmentsPage() {
|
|||||||
|
|
||||||
const handleDeleteDepartment = async (deptId: string) => {
|
const handleDeleteDepartment = async (deptId: string) => {
|
||||||
if (!orgId || !confirm("Are you sure you want to delete this department?")) return;
|
if (!orgId || !confirm("Are you sure you want to delete this department?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.organizations.deleteDepartment(orgId, deptId);
|
await api.organizations.deleteDepartment(orgId, deptId);
|
||||||
await fetchDepartments(orgId);
|
await fetchDepartments(orgId);
|
||||||
@@ -174,49 +206,85 @@ export default function DepartmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{filteredDepartments.map((dept) => (
|
{filteredDepartments.map((dept) => {
|
||||||
<div key={dept.id} className="p-4 flex items-start gap-4">
|
const principals = linkedPrincipals[dept.id] || [];
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center flex-shrink-0">
|
return (
|
||||||
<Users className="w-4 h-4" />
|
<div key={dept.id} className="p-4 flex items-start gap-4">
|
||||||
</div>
|
<div className="w-10 h-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center flex-shrink-0">
|
||||||
<div className="flex-1 min-w-0">
|
<Users className="w-4 h-4" />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{dept.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{dept.description && (
|
<div className="flex-1 min-w-0">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{dept.description}
|
<p className="font-medium text-foreground">
|
||||||
</p>
|
{dept.name}
|
||||||
)}
|
</p>
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
</div>
|
||||||
Created {new Date(dept.created_at).toLocaleDateString()}
|
{dept.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{dept.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Linked principals */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{principals.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground italic">
|
||||||
|
Not linked to any principal
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
principals.map((principal) => {
|
||||||
|
const key = `${dept.id}:${principal.id}`;
|
||||||
|
const busy = unlinkingKey === key;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={principal.id}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
|
||||||
|
>
|
||||||
|
{principal.name}
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlink(dept.id, principal.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="ml-0.5 rounded-full hover:bg-purple-200 dark:hover:bg-purple-800 disabled:opacity-50 p-0.5"
|
||||||
|
aria-label={`Unlink ${principal.name}`}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Created {new Date(dept.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(dept)}>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteDepartment(dept.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
);
|
||||||
<DropdownMenuTrigger asChild>
|
})}
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => openEditDialog(dept)}>
|
|
||||||
<Edit2 className="w-4 h-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => handleDeleteDepartment(dept.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api";
|
import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useOrganizations } from "@/hooks/useOrganizations";
|
||||||
|
|
||||||
const MFA_MODE_LABELS: Record<string, { label: string; description: string }> = {
|
const MFA_MODE_LABELS: Record<string, { label: string; description: string }> = {
|
||||||
disabled: {
|
disabled: {
|
||||||
@@ -51,18 +52,13 @@ export default function PoliciesPage() {
|
|||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
// Fetch organizations to get current org
|
// Fetch organizations to get current org
|
||||||
const { data: orgsData, isLoading: orgsLoading } = useQuery({
|
const { data: organizations, isLoading: orgsLoading } = useOrganizations();
|
||||||
queryKey: ['organizations'],
|
|
||||||
queryFn: () => api.users.organizations({
|
|
||||||
on403: create403Handler(toast),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orgsData?.organizations && orgsData.organizations.length > 0) {
|
if (organizations && organizations.length > 0) {
|
||||||
setCurrentOrgId(orgsData.organizations[0].id);
|
setCurrentOrgId(organizations[0].id);
|
||||||
}
|
}
|
||||||
}, [orgsData]);
|
}, [organizations]);
|
||||||
|
|
||||||
// Fetch org policy
|
// Fetch org policy
|
||||||
const { data: policy, isLoading: policyLoading } = useQuery({
|
const { data: policy, isLoading: policyLoading } = useQuery({
|
||||||
|
|||||||
+189
-153
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, Link as LinkIcon, Unlink } from "lucide-react";
|
import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, Link as LinkIcon, X } from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -31,38 +31,46 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api, Principal, Department } from "@/lib/api";
|
import { api, Principal, Department } from "@/lib/api";
|
||||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function PrincipalsPage() {
|
export default function PrincipalsPage() {
|
||||||
const params = useParams<{ orgId?: string }>();
|
const params = useParams<{ orgId?: string }>();
|
||||||
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||||
const orgId = params.orgId || fallbackOrgId;
|
const orgId = params.orgId || fallbackOrgId;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||||
const [departments, setDepartments] = useState<Department[]>([]);
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [linkedDepts, setLinkedDepts] = useState<Record<string, Department[]>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
|
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
|
||||||
const [editingPrincipal, setEditingPrincipal] = useState<Principal | null>(null);
|
const [editingPrincipal, setEditingPrincipal] = useState<Principal | null>(null);
|
||||||
const [selectedPrincipalForLink, setSelectedPrincipalForLink] = useState<Principal | null>(null);
|
const [selectedPrincipalForLink, setSelectedPrincipalForLink] = useState<Principal | null>(null);
|
||||||
const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
|
const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
|
||||||
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
const [unlinkingKey, setUnlinkingKey] = useState<string | null>(null);
|
||||||
const [formData, setFormData] = useState({ name: "", description: "" });
|
const [formData, setFormData] = useState({ name: "", description: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchLinkedDepts = useCallback(async (currentOrgId: string, principalList: Principal[]) => {
|
||||||
setError(null);
|
const entries = await Promise.all(
|
||||||
setPrincipals([]);
|
principalList.map(async (p) => {
|
||||||
setDepartments([]);
|
try {
|
||||||
if (!orgId) {
|
const res = await api.organizations.getPrincipalDepartments(currentOrgId, p.id);
|
||||||
setIsLoading(false);
|
return [p.id, res.departments] as [string, Department[]];
|
||||||
return;
|
} catch {
|
||||||
}
|
return [p.id, []] as [string, Department[]];
|
||||||
fetchData(orgId);
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
})
|
||||||
}, [orgId]);
|
);
|
||||||
|
setLinkedDepts(Object.fromEntries(entries));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchData = async (currentOrgId: string) => {
|
const fetchData = useCallback(async (currentOrgId: string) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -70,84 +78,97 @@ export default function PrincipalsPage() {
|
|||||||
api.organizations.getPrincipals(currentOrgId),
|
api.organizations.getPrincipals(currentOrgId),
|
||||||
api.organizations.getDepartments(currentOrgId),
|
api.organizations.getDepartments(currentOrgId),
|
||||||
]);
|
]);
|
||||||
setPrincipals(principalsRes.principals || []);
|
const pList = principalsRes.principals || [];
|
||||||
|
setPrincipals(pList);
|
||||||
setDepartments(deptRes.departments || []);
|
setDepartments(deptRes.departments || []);
|
||||||
|
await fetchLinkedDepts(currentOrgId, pList);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch data:", err);
|
console.error("Failed to fetch data:", err);
|
||||||
setError("Failed to load data. Please try again.");
|
setError("Failed to load data. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [fetchLinkedDepts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
setPrincipals([]);
|
||||||
|
setDepartments([]);
|
||||||
|
setLinkedDepts({});
|
||||||
|
if (!orgId) { setIsLoading(false); return; }
|
||||||
|
fetchData(orgId);
|
||||||
|
}, [orgId, fetchData]);
|
||||||
|
|
||||||
const handleCreatePrincipal = async () => {
|
const handleCreatePrincipal = async () => {
|
||||||
if (!orgId || !formData.name.trim()) return;
|
if (!orgId || !formData.name.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.organizations.createPrincipal(
|
await api.organizations.createPrincipal(orgId, formData.name, formData.description || undefined);
|
||||||
orgId,
|
|
||||||
formData.name,
|
|
||||||
formData.description || undefined
|
|
||||||
);
|
|
||||||
setFormData({ name: "", description: "" });
|
setFormData({ name: "", description: "" });
|
||||||
setIsCreateDialogOpen(false);
|
setIsCreateDialogOpen(false);
|
||||||
await fetchData(orgId);
|
await fetchData(orgId);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error("Failed to create principal:");
|
toast({ variant: "destructive", title: "Failed to create principal" });
|
||||||
setError("Failed to create principal.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdatePrincipal = async () => {
|
const handleUpdatePrincipal = async () => {
|
||||||
if (!orgId || !editingPrincipal || !formData.name.trim()) return;
|
if (!orgId || !editingPrincipal || !formData.name.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.organizations.updatePrincipal(
|
await api.organizations.updatePrincipal(orgId, editingPrincipal.id, {
|
||||||
orgId,
|
name: formData.name,
|
||||||
editingPrincipal.id,
|
description: formData.description || undefined,
|
||||||
{
|
});
|
||||||
name: formData.name,
|
|
||||||
description: formData.description || undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setFormData({ name: "", description: "" });
|
setFormData({ name: "", description: "" });
|
||||||
setEditingPrincipal(null);
|
setEditingPrincipal(null);
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
await fetchData(orgId);
|
await fetchData(orgId);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error("Failed to update principal:");
|
toast({ variant: "destructive", title: "Failed to update principal" });
|
||||||
setError("Failed to update principal.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePrincipal = async (principalId: string) => {
|
const handleDeletePrincipal = async (principalId: string) => {
|
||||||
if (!orgId || !confirm("Are you sure you want to delete this principal?")) return;
|
if (!orgId || !confirm("Are you sure you want to delete this principal?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.organizations.deletePrincipal(orgId, principalId);
|
await api.organizations.deletePrincipal(orgId, principalId);
|
||||||
await fetchData(orgId);
|
await fetchData(orgId);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error("Failed to delete principal:");
|
toast({ variant: "destructive", title: "Failed to delete principal" });
|
||||||
setError("Failed to delete principal.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLinkPrincipal = async () => {
|
const handleLinkPrincipal = async () => {
|
||||||
if (!orgId || !selectedPrincipalForLink || !selectedDepartmentId) return;
|
if (!orgId || !selectedPrincipalForLink || !selectedDepartmentId) return;
|
||||||
|
setIsLinking(true);
|
||||||
try {
|
try {
|
||||||
await api.organizations.linkPrincipalToDepartment(
|
await api.organizations.linkPrincipalToDepartment(orgId, selectedPrincipalForLink.id, selectedDepartmentId);
|
||||||
orgId,
|
toast({ title: "Principal linked to department" });
|
||||||
selectedPrincipalForLink.id,
|
|
||||||
selectedDepartmentId
|
|
||||||
);
|
|
||||||
setSelectedPrincipalForLink(null);
|
setSelectedPrincipalForLink(null);
|
||||||
setSelectedDepartmentId("");
|
setSelectedDepartmentId("");
|
||||||
setIsLinkDialogOpen(false);
|
setIsLinkDialogOpen(false);
|
||||||
await fetchData(orgId);
|
await fetchData(orgId);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error("Failed to link principal:");
|
toast({ variant: "destructive", title: "Failed to link principal to department" });
|
||||||
setError("Failed to link principal to department.");
|
} finally {
|
||||||
|
setIsLinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlink = async (principalId: string, deptId: string) => {
|
||||||
|
if (!orgId) return;
|
||||||
|
const key = `${principalId}:${deptId}`;
|
||||||
|
setUnlinkingKey(key);
|
||||||
|
try {
|
||||||
|
await api.organizations.unlinkPrincipalFromDepartment(orgId, principalId, deptId);
|
||||||
|
toast({ title: "Unlinked from department" });
|
||||||
|
setLinkedDepts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[principalId]: (prev[principalId] || []).filter((d) => d.id !== deptId),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
toast({ variant: "destructive", title: "Failed to unlink" });
|
||||||
|
} finally {
|
||||||
|
setUnlinkingKey(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,22 +184,22 @@ export default function PrincipalsPage() {
|
|||||||
setIsLinkDialogOpen(true);
|
setIsLinkDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPrincipals = principals.filter((principal) => {
|
const filteredPrincipals = principals.filter((p) => {
|
||||||
const searchLower = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
return (
|
return p.name.toLowerCase().includes(s) || (p.description?.toLowerCase().includes(s) ?? false);
|
||||||
principal.name.toLowerCase().includes(searchLower) ||
|
|
||||||
(principal.description?.toLowerCase().includes(searchLower) ?? false)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only show departments not already linked
|
||||||
|
const availableToLink = selectedPrincipalForLink
|
||||||
|
? departments.filter((d) => !(linkedDepts[selectedPrincipalForLink.id] || []).some((ld) => ld.id === d.id))
|
||||||
|
: departments;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Principals</h1>
|
<h1 className="page-title">Principals</h1>
|
||||||
<p className="page-description">
|
<p className="page-description">Manage principals and link them to departments</p>
|
||||||
Manage principals and link them to departments
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@@ -198,6 +219,8 @@ export default function PrincipalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mb-4 p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -205,63 +228,82 @@ export default function PrincipalsPage() {
|
|||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-muted-foreground">Loading principals...</span>
|
<span className="ml-2 text-muted-foreground">Loading principals...</span>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
|
||||||
<div className="p-8 text-center text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : filteredPrincipals.length === 0 ? (
|
) : filteredPrincipals.length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
<div className="p-8 text-center text-muted-foreground">No principals found</div>
|
||||||
No principals found
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{filteredPrincipals.map((principal) => (
|
{filteredPrincipals.map((principal) => {
|
||||||
<div key={principal.id} className="p-4 flex items-start gap-4">
|
const linked = linkedDepts[principal.id] || [];
|
||||||
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 flex items-center justify-center flex-shrink-0">
|
return (
|
||||||
<Users className="w-4 h-4" />
|
<div key={principal.id} className="p-4 flex items-start gap-4">
|
||||||
</div>
|
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<div className="flex-1 min-w-0">
|
<Users className="w-4 h-4" />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{principal.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{principal.description && (
|
<div className="flex-1 min-w-0">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="font-medium text-foreground">{principal.name}</p>
|
||||||
{principal.description}
|
{principal.description && (
|
||||||
</p>
|
<p className="mt-0.5 text-sm text-muted-foreground">{principal.description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Created {new Date(principal.created_at).toLocaleDateString()}
|
{/* Linked department tags */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{linked.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground italic">Not linked to any department</span>
|
||||||
|
) : linked.map((dept) => {
|
||||||
|
const key = `${principal.id}:${dept.id}`;
|
||||||
|
const busy = unlinkingKey === key;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={dept.id}
|
||||||
|
className="inline-flex items-center gap-1 text-xs bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800 rounded-full pl-2.5 pr-1 py-0.5"
|
||||||
|
>
|
||||||
|
{dept.name}
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlink(principal.id, dept.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-full p-0.5 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50 transition-colors"
|
||||||
|
title="Unlink from department"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="w-3 h-3 animate-spin" /> : <X className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Created {new Date(principal.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(principal)}>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openLinkDialog(principal)}>
|
||||||
|
<LinkIcon className="w-4 h-4 mr-2" />
|
||||||
|
Link to Department
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeletePrincipal(principal.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
);
|
||||||
<DropdownMenuTrigger asChild>
|
})}
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => openEditDialog(principal)}>
|
|
||||||
<Edit2 className="w-4 h-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => openLinkDialog(principal)}>
|
|
||||||
<LinkIcon className="w-4 h-4 mr-2" />
|
|
||||||
Link to Department
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => handleDeletePrincipal(principal.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -272,16 +314,14 @@ export default function PrincipalsPage() {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Principal</DialogTitle>
|
<DialogTitle>Create Principal</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Create a new principal to manage access and permissions</DialogDescription>
|
||||||
Create a new principal to manage access and permissions
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="principal-name">Principal Name</Label>
|
<Label htmlFor="principal-name">Principal Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="principal-name"
|
id="principal-name"
|
||||||
placeholder="e.g., Backend Team"
|
placeholder="e.g., eng-prod"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -298,12 +338,8 @@ export default function PrincipalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>Cancel</Button>
|
||||||
Cancel
|
<Button onClick={handleCreatePrincipal} disabled={!formData.name.trim()}>Create Principal</Button>
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreatePrincipal}>
|
|
||||||
Create Principal
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -313,16 +349,13 @@ export default function PrincipalsPage() {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Principal</DialogTitle>
|
<DialogTitle>Edit Principal</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Update principal information</DialogDescription>
|
||||||
Update principal information
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-principal-name">Principal Name</Label>
|
<Label htmlFor="edit-principal-name">Principal Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-principal-name"
|
id="edit-principal-name"
|
||||||
placeholder="e.g., Backend Team"
|
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -339,52 +372,55 @@ export default function PrincipalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Cancel</Button>
|
||||||
Cancel
|
<Button onClick={handleUpdatePrincipal} disabled={!formData.name.trim()}>Save Changes</Button>
|
||||||
</Button>
|
|
||||||
<Button onClick={handleUpdatePrincipal}>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Link Principal to Department Dialog */}
|
{/* Link to Department Dialog */}
|
||||||
<Dialog open={isLinkDialogOpen} onOpenChange={setIsLinkDialogOpen}>
|
<Dialog
|
||||||
|
open={isLinkDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) { setSelectedPrincipalForLink(null); setSelectedDepartmentId(""); }
|
||||||
|
setIsLinkDialogOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Link Principal to Department</DialogTitle>
|
<DialogTitle>Link to Department</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Associate this principal with a department
|
Link <strong>{selectedPrincipalForLink?.name}</strong> to a department
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="dept-select">Select Department</Label>
|
<Label htmlFor="dept-select">Department</Label>
|
||||||
<Select value={selectedDepartmentId} onValueChange={setSelectedDepartmentId}>
|
{availableToLink.length === 0 ? (
|
||||||
<SelectTrigger id="dept-select">
|
<p className="mt-2 text-sm text-muted-foreground">Already linked to all available departments.</p>
|
||||||
<SelectValue placeholder="Choose a department..." />
|
) : (
|
||||||
</SelectTrigger>
|
<Select value={selectedDepartmentId} onValueChange={setSelectedDepartmentId}>
|
||||||
<SelectContent>
|
<SelectTrigger id="dept-select" className="mt-1">
|
||||||
{departments.map((dept) => (
|
<SelectValue placeholder="Choose a department..." />
|
||||||
<SelectItem key={dept.id} value={dept.id}>
|
</SelectTrigger>
|
||||||
{dept.name}
|
<SelectContent>
|
||||||
</SelectItem>
|
{availableToLink.map((dept) => (
|
||||||
))}
|
<SelectItem key={dept.id} value={dept.id}>{dept.name}</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsLinkDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsLinkDialogOpen(false)}>Cancel</Button>
|
||||||
Cancel
|
<Button onClick={handleLinkPrincipal} disabled={!selectedDepartmentId || isLinking || availableToLink.length === 0}>
|
||||||
</Button>
|
{isLinking && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
<Button onClick={handleLinkPrincipal} disabled={!selectedDepartmentId}>
|
Link
|
||||||
Link Principal
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user