diff --git a/src/App.tsx b/src/App.tsx index 8e4b6b2..4c7d7d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,8 +38,10 @@ import OIDCClientsPage from "@/pages/org/OIDCClientsPage"; import CAsPage from "@/pages/org/CAsPage"; import DepartmentsPage from "@/pages/org/DepartmentsPage"; import PrincipalsPage from "@/pages/org/PrincipalsPage"; +import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import AdminUsersPage from "@/pages/admin/AdminUsersPage"; +import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import NotFound from "@/pages/NotFound"; import ApiDevTools from "@/components/dev/ApiDevTools"; @@ -78,8 +80,30 @@ 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(); + // Allow authenticated users through to /login when it's a CLI auth request — + // LoginPage will immediately forward the existing token to the CLI callback. + const params = new URLSearchParams(window.location.search); + const isCli = params.has('cli_token') || params.has('cli_redirect'); if (isLoading) return null; // wait for auth state to resolve - if (isAuthenticated) return ; + if (isAuthenticated && !isCli) return ; + return <>{children}; +} + +/** Blocks access to /admin/* for non-admin users. */ +function RequireAdmin({ children }: { children: React.ReactNode }) { + const { isOrgAdmin, isLoading, isAuthenticated } = useAuth(); + if (isLoading) return null; + if (!isAuthenticated) return ; + if (!isOrgAdmin) return ; + return <>{children}; +} + +/** Blocks access to /org/* for users who don't belong to any organisation. */ +function RequireOrgMember({ children }: { children: React.ReactNode }) { + const { isOrgMember, isLoading, isAuthenticated } = useAuth(); + if (isLoading) return null; + if (!isAuthenticated) return ; + if (!isOrgMember) return ; return <>{children}; } @@ -113,20 +137,24 @@ function AppRoutes() { } /> } /> - {/* Organization routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Organization routes — org members: overview + own memberships only */} + } /> + } /> - {/* Admin routes */} - } /> - } /> + {/* Organization management routes — org admins/owners only */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Admin routes — org admin/owner only */} + } /> + } /> + } /> {/* Catch-all */} diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index b42b953..d96fa98 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -8,7 +8,6 @@ import { Users, Settings, FileText, - Key, Layers, GitBranch, ScrollText, @@ -17,6 +16,7 @@ import { } from "lucide-react"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { NavLink } from "@/components/NavLink"; +import { useAuth } from "@/contexts/AuthContext"; import { Sidebar, SidebarContent, @@ -40,19 +40,25 @@ const userNavItems = [ { title: "Activity", url: "/activity", icon: Activity }, ]; -const orgNavItems = [ +// Visible to ALL org members +const orgMemberNavItems = [ + { title: "Overview", url: "/org", icon: Building2 }, + { title: "My Memberships", url: "/org/my-memberships", icon: Layers }, +]; + +// Visible to org admins/owners only (management) +const orgAdminNavItems = [ { title: "Overview", url: "/org", icon: Building2 }, { title: "Members", url: "/org/members", icon: Users }, { title: "Departments", url: "/org/departments", icon: Layers }, { title: "Principals", url: "/org/principals", icon: GitBranch }, { title: "Policies", url: "/org/policies", icon: Settings }, - { title: "Audit Log", url: "/org/audit", icon: FileText }, ]; const adminNavItems = [ - { title: "OIDC Clients", url: "/org/clients", icon: Key }, + { title: "Users", url: "/admin/users", icon: Users }, { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, - // { title: "Users", url: "/admin/users", icon: Users }, + { title: "Org Audit Log", url: "/org/audit", icon: FileText }, { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; @@ -60,10 +66,11 @@ export function AppSidebar() { const { state } = useSidebar(); const collapsed = state === "collapsed"; const location = useLocation(); + const { isOrgAdmin, isOrgMember } = useAuth(); const isActive = (path: string) => location.pathname === path; - const isOrgActive = orgNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); - const isUserActive = userNavItems.some((item) => isActive(item.url)); + const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); + void isOrgActive; // used for future active state tracking return ( {/* User Section */} - - {!collapsed && "Account"} - + {!collapsed && ( + + Account + + )} {userNavItems.map((item) => ( @@ -100,8 +109,11 @@ export function AppSidebar() { to={item.url} end className={cn( - "flex items-center gap-3 px-4 py-2.5 text-sm text-sidebar-foreground rounded-lg mx-2 transition-colors", - "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + "flex items-center text-sm text-sidebar-foreground rounded-lg transition-colors", + "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + collapsed + ? "justify-center w-10 h-10 mx-auto p-0" + : "gap-3 px-4 py-2.5 mx-2" )} activeClassName="bg-sidebar-accent text-sidebar-primary font-medium" > @@ -115,22 +127,28 @@ export function AppSidebar() { - {/* Organization Section */} + {/* Organization Section — content differs by role */} + {isOrgMember && ( - - {!collapsed && "Organization"} - + {!collapsed && ( + + Organization + + )} - {orgNavItems.map((item) => ( + {(isOrgAdmin ? orgAdminNavItems : orgMemberNavItems).map((item) => ( @@ -143,12 +161,16 @@ export function AppSidebar() { + )} - {/* Admin Section */} + {/* Admin Section — only visible to org admins/owners */} + {isOrgAdmin && ( - - {!collapsed && "Admin"} - + {!collapsed && ( + + Admin + + )} {adminNavItems.map((item) => ( @@ -158,8 +180,11 @@ export function AppSidebar() { to={item.url} end className={cn( - "flex items-center gap-3 px-4 py-2.5 text-sm text-sidebar-foreground rounded-lg mx-2 transition-colors", - "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + "flex items-center text-sm text-sidebar-foreground rounded-lg transition-colors", + "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + collapsed + ? "justify-center w-10 h-10 mx-auto p-0" + : "gap-3 px-4 py-2.5 mx-2" )} activeClassName="bg-sidebar-accent text-sidebar-primary font-medium" > @@ -172,6 +197,7 @@ export function AppSidebar() { + )} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4b7e5be..a9e0144 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -12,10 +12,12 @@ interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; + isOrgAdmin: boolean; + isOrgMember: boolean; mfaCompliance: MfaComplianceSummary | null; requiresMfaEnrollment: boolean; - login: (email: string, password: string, rememberMe?: boolean) => Promise; - verifyTotp: (code: string, isBackupCode?: boolean) => Promise; + login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise; + verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise; verifyWebAuthn: () => Promise; logout: () => Promise; refreshUser: () => Promise; @@ -40,66 +42,61 @@ function persistMfaCompliance(compliance: MfaComplianceSummary | null): void { function loadMfaCompliance(): MfaComplianceSummary | null { try { const stored = localStorage.getItem(MFA_COMPLIANCE_KEY); - if (!stored) { - console.log('[AuthContext] loadMfaCompliance: no stored data'); - return null; - } - + if (!stored) return null; + const parsed = JSON.parse(stored); - console.log('[AuthContext] loadMfaCompliance: raw parsed:', parsed); - + // Handle both direct format and legacy double-nested format // Legacy format: { mfa_compliance: { ... } } // Current format: { ... } let compliance: Record; if (parsed.mfa_compliance && typeof parsed.mfa_compliance === 'object') { - console.log('[AuthContext] loadMfaCompliance: detected legacy double-nested format, unwrapping'); compliance = parsed.mfa_compliance as Record; } else { compliance = parsed; } - + // Validate that the stored data has the required fields - if (!compliance || typeof compliance !== 'object') { - console.log('[AuthContext] loadMfaCompliance: invalid compliance object'); - return null; - } - if (!Array.isArray(compliance.orgs)) { - console.log('[AuthContext] loadMfaCompliance: orgs is not an array'); - return null; - } - - // Validate missing_methods exists and is an array - if (!Array.isArray(compliance.missing_methods)) { - console.log('[AuthContext] loadMfaCompliance: missing_methods is not an array or missing'); - } - + if (!compliance || typeof compliance !== 'object') return null; + if (!Array.isArray(compliance.orgs)) return null; + // Check if at least one org has effective_mode (new field from API) // If not, treat as stale data and return null to fetch fresh data const hasEffectiveMode = compliance.orgs.some((org: Record) => typeof org.effective_mode === 'string' ); - - if (!hasEffectiveMode) { - console.log('[AuthContext] loadMfaCompliance: no effective_mode found, treating as stale'); - return null; - } - - console.log('[AuthContext] loadMfaCompliance: loaded successfully'); + if (!hasEffectiveMode) return null; + return compliance as unknown as MfaComplianceSummary; - } catch (error) { - console.log('[AuthContext] loadMfaCompliance: error loading:', error); + } catch { return null; } } export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); + const [isOrgAdmin, setIsOrgAdmin] = useState(false); + const [isOrgMember, setIsOrgMember] = useState(false); const [mfaCompliance, setMfaCompliance] = useState(loadMfaCompliance); const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false); const [isLoading, setIsLoading] = useState(true); const navigate = useNavigate(); + // Helper to check if user is admin/owner in any org + const checkOrgAdmin = useCallback(async () => { + try { + const data = await api.users.organizations(); + const admin = data.organizations.some( + (org) => org.role === 'owner' || org.role === 'admin' + ); + setIsOrgAdmin(admin); + setIsOrgMember(data.organizations.length > 0); + } catch { + setIsOrgAdmin(false); + setIsOrgMember(false); + } + }, []); + const refreshCompliance = useCallback(async () => { try { const compliance = await api.policies.getMyCompliance(); @@ -148,7 +145,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const response = await api.users.me(); setUser(response.user); - // Also fetch compliance status + // Also fetch compliance status and org role try { const compliance = await api.policies.getMyCompliance(); setMfaCompliance(compliance); @@ -159,8 +156,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { setMfaCompliance(null); persistMfaCompliance(null); } + // Check org admin status + await checkOrgAdmin(); } catch { setUser(null); + setIsOrgAdmin(false); + setIsOrgMember(false); setMfaCompliance(null); persistMfaCompliance(null); setRequiresMfaEnrollment(false); @@ -172,32 +173,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { checkAuth(); }, []); - const login = useCallback(async (email: string, password: string, rememberMe = false): Promise => { - console.log('[AuthContext] login() called'); + const login = useCallback(async (email: string, password: string, rememberMe = false, skipNavigate = false): Promise => { const response = await api.auth.login(email, password, rememberMe); - console.log('[AuthContext] login response:', { - requires_totp: response.requires_totp, - requires_webauthn: response.requires_webauthn, - requires_mfa_enrollment: response.requires_mfa_enrollment, - hasToken: !!response.token, - hasUser: !!response.user - }); - + // If WebAuthn is required, don't set user yet - wait for WebAuthn verification if (response.requires_webauthn) { - console.log('[AuthContext] WebAuthn required, returning early'); return { requiresTotp: false, requiresWebAuthn: true }; } - + // If TOTP is required, don't set user yet - wait for TOTP verification if (response.requires_totp) { - console.log('[AuthContext] TOTP required, returning early'); return { requiresTotp: true, requiresWebAuthn: false }; } - + // If MFA enrollment is required (past deadline), set compliance state if (response.requires_mfa_enrollment) { - console.log('[AuthContext] MFA enrollment required, setting compliance state'); if (response.token) { tokenManager.setToken(response.token, response.expires_at ?? null); } @@ -211,48 +201,39 @@ export function AuthProvider({ children }: { children: ReactNode }) { setRequiresMfaEnrollment(true); return { requiresTotp: false, requiresWebAuthn: false, requiresMfaEnrollment: true }; } - - // Login complete: store token explicitly before setting user state - // This ensures the token is available for any subsequent API calls - // (e.g., when navigate('/profile') triggers refreshUser()) + if (response.token) { - console.log('[AuthContext] Storing token in localStorage'); tokenManager.setToken(response.token, response.expires_at ?? null); - console.log('[AuthContext] Token stored, verifying:', tokenManager.getToken()?.substring(0, 20) + '...'); } - - // Set user and navigate + if (response.user) { - console.log('[AuthContext] Setting user state and navigating to /profile'); setUser(response.user); if (response.mfa_compliance) { setMfaCompliance(response.mfa_compliance); persistMfaCompliance(response.mfa_compliance); } setRequiresMfaEnrollment(false); - navigate('/profile'); + await checkOrgAdmin(); + if (!skipNavigate) { + navigate('/profile'); + } } return { requiresTotp: false, requiresWebAuthn: false }; - }, [navigate]); + }, [navigate, checkOrgAdmin]); const verifyWebAuthn = useCallback(async () => { // WebAuthn verification is handled directly in the LoginPage component - // This is a placeholder for consistency with the interface - console.log('[AuthContext] verifyWebAuthn called - verification handled in LoginPage'); }, []); - const verifyTotp = useCallback(async (code: string, isBackupCode = false) => { + const verifyTotp = useCallback(async (code: string, isBackupCode = false, skipNavigate = false) => { const response = await api.totp.verify(code, isBackupCode); - // Store token explicitly before setting user state - // This ensures the token is available for any subsequent API calls if (response.token) { tokenManager.setToken(response.token, response.expires_at ?? null); } setUser(response.user); - // Check for MFA compliance in response try { const compliance = await api.policies.getMyCompliance(); setMfaCompliance(compliance); @@ -263,14 +244,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { persistMfaCompliance(null); } - navigate('/profile'); - }, [navigate]); + await checkOrgAdmin(); + if (!skipNavigate) { + navigate('/profile'); + } + }, [navigate, checkOrgAdmin]); const logout = useCallback(async () => { try { await api.auth.logout(); } finally { setUser(null); + setIsOrgAdmin(false); + setIsOrgMember(false); setMfaCompliance(null); persistMfaCompliance(null); setRequiresMfaEnrollment(false); @@ -284,6 +270,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { user, isLoading, isAuthenticated: !!user, + isOrgAdmin, + isOrgMember, mfaCompliance, requiresMfaEnrollment, login, diff --git a/src/lib/api.ts b/src/lib/api.ts index ac4d37d..d13eb7a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -25,6 +25,10 @@ export interface User { last_login_at: string | null; created_at: string; updated_at: string; + // Fields present in admin list view + org_role?: string; + org_id?: string; + activated?: boolean; } export interface Organization { @@ -471,6 +475,14 @@ export const api = { true, requestConfig, ), + + // Get pending (unaccepted) invitations for the logged-in user + getMyInvites: (requestConfig?: RequestConfig) => + request<{ invites: PendingInvite[] }>('/users/me/invites', {}, true, requestConfig), + + // Get the current user's department + principal memberships across all orgs + getMyMemberships: (requestConfig?: RequestConfig) => + request<{ orgs: MyOrgMembership[] }>('/users/me/memberships', {}, true, requestConfig), }, admin: { @@ -495,6 +507,51 @@ export const api = { // 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), + + // Update a user's role in a shared org (admin action) + updateUserRole: (orgId: string, userId: string, role: string, requestConfig?: RequestConfig) => + request<{ member: OrganizationMember }>(`/organizations/${orgId}/members/${userId}/role`, { + method: 'PATCH', + body: JSON.stringify({ role }), + }, true, requestConfig), + + // List application-level OAuth provider configurations + listOAuthProviders: (requestConfig?: RequestConfig) => + request<{ providers: { id: string; name: string; is_configured: boolean; is_enabled: boolean; client_id: string | null }[] }>( + '/admin/oauth/providers', {}, true, requestConfig, + ), + + // Create or update an application-level OAuth provider + configureOAuthProvider: (provider: string, clientId: string, clientSecret: string, isEnabled: boolean, requestConfig?: RequestConfig) => + request<{ provider: { id: string; client_id: string; is_enabled: boolean } }>( + `/admin/oauth/providers/${provider}`, + { method: 'PUT', body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, is_enabled: isEnabled }) }, + true, + requestConfig, + ), + + // Delete an application-level OAuth provider + deleteOAuthProvider: (provider: string, requestConfig?: RequestConfig) => + request>(`/admin/oauth/providers/${provider}`, { method: 'DELETE' }, true, requestConfig), + + // Suspend a user account (blocks login & CA issuance) + suspendUser: (userId: string, requestConfig?: RequestConfig) => + request<{ user: User }>(`/admin/users/${userId}/suspend`, { method: 'POST' }, true, requestConfig), + + // Restore a suspended user to active status + unsuspendUser: (userId: string, requestConfig?: RequestConfig) => + request<{ user: User }>(`/admin/users/${userId}/unsuspend`, { method: 'POST' }, true, requestConfig), + + // Get the cert policy for a department + getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, {}, true, requestConfig), + + // Create or update the cert policy for a department + setDeptCertPolicy: (orgId: string, deptId: string, policy: Partial, requestConfig?: RequestConfig) => + request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, { + method: 'PUT', + body: JSON.stringify(policy), + }, true, requestConfig), }, totp: { @@ -873,6 +930,16 @@ export const api = { body: JSON.stringify({ email, role }), }, true, requestConfig), + // List pending invites for an organization + getInvites: (orgId: string, requestConfig?: RequestConfig) => + request<{ invites: OrgInvite[] }>(`/organizations/${orgId}/invites`, {}, true, requestConfig), + + // Cancel (delete) an invite + cancelInvite: (orgId: string, inviteId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/invites/${inviteId}`, { + method: 'DELETE', + }, true, requestConfig), + // List OIDC clients getClients: (orgId: string, requestConfig?: RequestConfig) => request<{ clients: OIDCClient[]; count: number }>(`/organizations/${orgId}/clients`, {}, true, requestConfig), @@ -918,14 +985,14 @@ export const api = { invites: { // Get invite details by token (unauthenticated) getInfo: (token: string) => - request<{ email: string; organization: { id: string; name: string }; role: string }>( + request<{ email: string; organization: { id: string; name: string }; role: string; user_exists?: boolean }>( `/invites/${token}`, {}, false, ), - // Accept invite (unauthenticated) - accept: (token: string, full_name: string, password: string) => + // Accept invite (unauthenticated) — password/name only needed for new accounts + accept: (token: string, full_name?: string, password?: string) => request( `/invites/${token}/accept`, { @@ -979,6 +1046,10 @@ export const api = { body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }), }, true, requestConfig), + // Get the merged department certificate policy for the current user (used in sign dialog) + getMyDeptCertPolicy: (requestConfig?: RequestConfig) => + request<{ policy: DeptCertPolicy }>('/ssh/dept-cert-policy', {}, true, requestConfig), + // List issued certificates for the current user listCertificates: (requestConfig?: RequestConfig) => request<{ certificates: SSHCertificate[]; count: number }>('/ssh/certificates', {}, true, requestConfig), @@ -1064,6 +1135,40 @@ export interface Department { deleted_at: string | null; } +export const STANDARD_SSH_EXTENSIONS = [ + 'permit-X11-forwarding', + 'permit-agent-forwarding', + 'permit-pty', + 'permit-port-forwarding', + 'permit-user-rc', +] as const; + +export interface DeptCertPolicy { + department_id: string; + allow_user_expiry: boolean; + default_expiry_hours: number; + max_expiry_hours: number; + allowed_extensions: string[]; + custom_extensions: string[]; + all_extensions?: string[]; + standard_extensions?: string[]; +} + +export interface PendingInvite { + token: string; + organization: { id: string; name: string }; + role: string; + expires_at: string; +} + +export interface MyOrgMembership { + org_id: string; + org_name: string; + role: string; + departments: { id: string; name: string; description: string | null }[]; + principals: { id: string; name: string; description: string | null; via_department: boolean }[]; +} + export interface DepartmentMember { id: string; user_id: string; @@ -1097,6 +1202,7 @@ export interface OrgInvite { email: string; role: string; expires_at: string; + invite_link?: string; // only present on create response (dev/when email disabled) } export interface OIDCClient { diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 9afd20b..629bf36 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -8,6 +8,11 @@ import { Loader2, Plus, ChevronRight, + ShieldCheck, + Shield, + Ban, + UserCheck, + AlertTriangle, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -36,16 +41,48 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { api, User as ApiUser, SSHKey, ApiError } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; function formatDate(d: string | null) { if (!d) return "—"; return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } +function RoleBadge({ role }: { role: string }) { + const r = (role || "").toLowerCase(); + if (r === "owner") { + return ( + + Owner + + ); + } + if (r === "admin") { + return ( + + Admin + + ); + } + return ( + + Member + + ); +} + export default function AdminUsersPage() { const { toast } = useToast(); + const { user: currentUser } = useAuth(); // User list const [users, setUsers] = useState([]); @@ -55,6 +92,7 @@ export default function AdminUsersPage() { const [isLoading, setIsLoading] = useState(false); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState("all"); // Debounce search useEffect(() => { @@ -67,6 +105,9 @@ export default function AdminUsersPage() { const [userSshKeys, setUserSshKeys] = useState([]); const [isDrawerLoading, setIsDrawerLoading] = useState(false); + // Role update + const [isUpdatingRole, setIsUpdatingRole] = useState(false); + // Admin add SSH key dialog const [showAddKey, setShowAddKey] = useState(false); const [addKeyPublicKey, setAddKeyPublicKey] = useState(""); @@ -74,6 +115,10 @@ export default function AdminUsersPage() { const [isAddingKey, setIsAddingKey] = useState(false); const [addKeyError, setAddKeyError] = useState(null); + // Suspend / unsuspend + const [isSuspending, setIsSuspending] = useState(false); + const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); + // ── Fetch users ───────────────────────────────────────────────────────────── const fetchUsers = useCallback(async (q: string, pg: number) => { setIsLoading(true); @@ -123,6 +168,32 @@ export default function AdminUsersPage() { } }; + // ── Update role ────────────────────────────────────────────────────────────── + const handleRoleChange = async (newRole: string) => { + if (!selectedUser || !selectedUser.org_id) return; + setIsUpdatingRole(true); + try { + await api.admin.updateUserRole(selectedUser.org_id, selectedUser.id, newRole.toUpperCase()); + const updated = { ...selectedUser, org_role: newRole }; + setSelectedUser(updated); + setUsers((prev) => + prev.map((u) => (u.id === selectedUser.id ? { ...u, org_role: newRole } : u)) + ); + toast({ + title: "Role updated", + description: `${selectedUser.full_name || selectedUser.email} is now a ${newRole}.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to update role", + description: err instanceof ApiError ? err.message : "Something went wrong", + }); + } finally { + setIsUpdatingRole(false); + } + }; + // ── Admin add SSH key ──────────────────────────────────────────────────────── const handleAddKey = async () => { if (!selectedUser) return; @@ -146,6 +217,47 @@ export default function AdminUsersPage() { } }; + // ── Suspend / Unsuspend user ───────────────────────────────────────────────── + const handleSuspend = async () => { + if (!selectedUser) return; + setIsSuspending(true); + try { + const data = await api.admin.suspendUser(selectedUser.id); + const updated = { ...selectedUser, status: data.user.status }; + setSelectedUser(updated); + setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u)); + setShowSuspendConfirm(false); + toast({ title: "User suspended", description: `${selectedUser.full_name || selectedUser.email} has been suspended.` }); + } catch (err) { + toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" }); + } finally { + setIsSuspending(false); + } + }; + + const handleUnsuspend = async () => { + if (!selectedUser) return; + setIsSuspending(true); + try { + const data = await api.admin.unsuspendUser(selectedUser.id); + const updated = { ...selectedUser, status: data.user.status }; + setSelectedUser(updated); + setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u)); + toast({ title: "User unsuspended", description: `${selectedUser.full_name || selectedUser.email} is now active.` }); + } catch (err) { + toast({ variant: "destructive", title: "Failed to unsuspend user", description: err instanceof ApiError ? err.message : "Something went wrong" }); + } finally { + setIsSuspending(false); + } + }; + + // Filter by role client-side + const filteredUsers = users.filter((u) => { + if (roleFilter === "all") return true; + const r = (u.org_role || "member").toLowerCase(); + return r === roleFilter; + }); + // ────────────────────────────────────────────────────────────────────────────── return (
@@ -156,15 +268,28 @@ export default function AdminUsersPage() {

- {/* Search bar */} -
- - setSearch(e.target.value)} - /> + {/* Search + filter bar */} +
+
+ + setSearch(e.target.value)} + /> +
+
@@ -174,21 +299,21 @@ export default function AdminUsersPage() { Users {!isLoading && {total}} - Click a user to view details and manage their SSH keys + Click a user to view details and manage their role or SSH keys {isLoading ? (
- ) : users.length === 0 ? ( + ) : filteredUsers.length === 0 ? (

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

) : (
- {users.map((user) => ( + {filteredUsers.map((user) => (
- {(user as ApiUser & { activated?: boolean }).activated === false && ( + + {user.status === "suspended" && ( + + Suspended + + )} + {user.activated === false && ( Not activated @@ -261,19 +392,98 @@ export default function AdminUsersPage() { {/* Basic info */}
+ Status + + {selectedUser.status === "suspended" ? ( + <>Suspended + ) : ( + <>Active + )} + Joined {formatDate(selectedUser.created_at)} Activated - {(selectedUser as ApiUser & { activated?: boolean }).activated === false ? ( + {selectedUser.activated === false ? ( <> No ) : ( <> Yes )} + Last login + {formatDate(selectedUser.last_login_at)}
+ {/* Suspend / Unsuspend — only for other users */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Account Access +

+ {selectedUser.status === "suspended" ? ( +
+

This account is suspended. The user cannot log in or request certificates.

+ +
+ ) : ( +
+

Suspending blocks this user from logging in and requesting SSH certificates.

+ +
+ )} +
+ )} + + {/* Role management — only if not viewing yourself and user has org_id */} + {selectedUser.org_id && selectedUser.id !== currentUser?.id && ( +
+

+ + Organization Role +

+
+ + {isUpdatingRole && } +
+ {(selectedUser.org_role || "").toLowerCase() === "owner" && ( +

Owner role cannot be changed here. Transfer ownership from the Members page.

+ )} +
+ )} + {/* SSH Keys section */}
@@ -371,6 +581,34 @@ export default function AdminUsersPage() { + + {/* ── Suspend confirmation dialog ───────────────────────────────────────── */} + + + + + + Suspend account? + + + {selectedUser?.full_name || selectedUser?.email} will be blocked from logging in and requesting SSH certificates. You can restore their access at any time. + + + + + + + +
); } diff --git a/src/pages/admin/OAuthProvidersPage.tsx b/src/pages/admin/OAuthProvidersPage.tsx new file mode 100644 index 0000000..20be9a9 --- /dev/null +++ b/src/pages/admin/OAuthProvidersPage.tsx @@ -0,0 +1,312 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useToast } from "@/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +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 { Loader2, Settings, Trash2, Plus, Eye, EyeOff } from "lucide-react"; + +interface OAuthProvider { + id: string; + name: string; + is_configured: boolean; + is_enabled: boolean; + client_id: string | null; +} + +const PROVIDER_LOGOS: Record = { + google: "https://www.google.com/favicon.ico", + github: "https://github.com/favicon.ico", + microsoft: "https://www.microsoft.com/favicon.ico", +}; + +const PROVIDER_HELP: Record = { + google: { + docsUrl: "https://console.cloud.google.com/apis/credentials", + callbackNote: "Authorized redirect URI: http://localhost:5000/api/v1/auth/external/google/callback", + }, + github: { + docsUrl: "https://github.com/settings/applications/new", + callbackNote: "Authorization callback URL: http://localhost:5000/api/v1/auth/external/github/callback", + }, + microsoft: { + docsUrl: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps", + callbackNote: "Redirect URI: http://localhost:5000/api/v1/auth/external/microsoft/callback", + }, +}; + +export default function OAuthProvidersPage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const [configDialog, setConfigDialog] = useState<{ open: boolean; provider: OAuthProvider | null }>({ + open: false, + provider: null, + }); + const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; provider: OAuthProvider | null }>({ + open: false, + provider: null, + }); + + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [isEnabled, setIsEnabled] = useState(true); + const [showSecret, setShowSecret] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ["admin", "oauthProviders"], + queryFn: () => api.admin.listOAuthProviders(), + }); + + const configureMutation = useMutation({ + mutationFn: ({ provider, cid, cs, enabled }: { provider: string; cid: string; cs: string; enabled: boolean }) => + api.admin.configureOAuthProvider(provider, cid, cs, enabled), + onSuccess: (_, { provider }) => { + queryClient.invalidateQueries({ queryKey: ["admin", "oauthProviders"] }); + toast({ title: `${provider} configured`, description: "OAuth provider settings saved." }); + setConfigDialog({ open: false, provider: null }); + }, + onError: (err: Error) => { + toast({ title: "Failed to save", description: err.message, variant: "destructive" }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (provider: string) => api.admin.deleteOAuthProvider(provider), + onSuccess: (_, provider) => { + queryClient.invalidateQueries({ queryKey: ["admin", "oauthProviders"] }); + toast({ title: `${provider} removed`, description: "OAuth provider configuration deleted." }); + setDeleteDialog({ open: false, provider: null }); + }, + onError: (err: Error) => { + toast({ title: "Failed to delete", description: err.message, variant: "destructive" }); + }, + }); + + const openConfig = (provider: OAuthProvider) => { + setClientId(provider.client_id ?? ""); + setClientSecret(""); + setIsEnabled(provider.is_enabled); + setShowSecret(false); + setConfigDialog({ open: true, provider }); + }; + + const handleSave = () => { + if (!configDialog.provider) return; + configureMutation.mutate({ + provider: configDialog.provider.id, + cid: clientId, + cs: clientSecret, + enabled: isEnabled, + }); + }; + + const providers: OAuthProvider[] = data?.providers ?? []; + + return ( +
+
+

OAuth Providers

+

+ Configure application-level OAuth provider credentials. Users can link their accounts via these providers. +

+
+ + {isLoading && ( +
+ + Loading providers… +
+ )} + +
+ {providers.map((p) => { + const help = PROVIDER_HELP[p.id]; + return ( + + +
+
+ {p.name} (e.currentTarget.style.display = "none")} + /> + {p.name} + {p.is_configured ? ( + + {p.is_enabled ? "Enabled" : "Disabled"} + + ) : ( + + Not configured + + )} +
+
+ + {p.is_configured && ( + + )} +
+
+
+ {p.is_configured && p.client_id && ( + + + Client ID: {p.client_id.slice(0, 24)}… + + + )} + {!p.is_configured && ( + + + {help.callbackNote} + + + )} +
+ ); + })} +
+ + {/* Configure Dialog */} + setConfigDialog((s) => ({ ...s, open: o }))}> + + + + {configDialog.provider?.is_configured ? "Edit" : "Configure"}{" "} + {configDialog.provider?.name} OAuth + + + {configDialog.provider && PROVIDER_HELP[configDialog.provider.id]?.callbackNote} + {" "} + + Open provider console ↗ + + + + +
+
+ + setClientId(e.target.value)} + placeholder="Enter Client ID" + /> +
+ +
+ +
+ setClientSecret(e.target.value)} + placeholder={configDialog.provider?.is_configured ? "••••••••" : "Enter Client Secret"} + className="pr-10" + /> + +
+
+ +
+ + +
+
+ + + + + +
+
+ + {/* Delete Confirm Dialog */} + setDeleteDialog((s) => ({ ...s, open: o }))} + > + + + Remove {deleteDialog.provider?.name}? + + This will remove the OAuth credentials for {deleteDialog.provider?.name}. Users will no longer be able + to sign in or link accounts via this provider. + + + + Cancel + deleteDialog.provider && deleteMutation.mutate(deleteDialog.provider.id)} + > + {deleteMutation.isPending ? : "Remove"} + + + + +
+ ); +} diff --git a/src/pages/auth/ForgotPasswordPage.tsx b/src/pages/auth/ForgotPasswordPage.tsx index e83ae8a..2d8bd89 100644 --- a/src/pages/auth/ForgotPasswordPage.tsx +++ b/src/pages/auth/ForgotPasswordPage.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { BannerAlert } from "@/components/auth/BannerAlert"; +import { api } from "@/lib/api"; export default function ForgotPasswordPage() { const [email, setEmail] = useState(""); @@ -15,11 +16,14 @@ export default function ForgotPasswordPage() { e.preventDefault(); setIsLoading(true); - // Mock API call - POST /api/auth/forgot-password - setTimeout(() => { + try { + await api.auth.forgotPassword(email); + } catch { + // Always show success to avoid leaking account existence + } finally { setIsLoading(false); setIsSubmitted(true); - }, 1000); + } }; // Success state - always show neutral message (don't leak account existence) diff --git a/src/pages/auth/InviteAcceptPage.tsx b/src/pages/auth/InviteAcceptPage.tsx index 3f8b1d4..85d41a9 100644 --- a/src/pages/auth/InviteAcceptPage.tsx +++ b/src/pages/auth/InviteAcceptPage.tsx @@ -1,36 +1,106 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { User, Lock, Upload, ArrowRight, Building2 } from "lucide-react"; +import { useState, useEffect } from "react"; +import { useNavigate, useSearchParams, Link } from "react-router-dom"; +import { User, Lock, ArrowRight, Building2, Loader2, AlertCircle, CheckCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { api, ApiError } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; export default function InviteAcceptPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") || ""; + const { login } = useAuth(); + const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isTokenLoading, setIsTokenLoading] = useState(true); + const [tokenError, setTokenError] = useState(""); + const [submitError, setSubmitError] = useState(""); + const [inviteData, setInviteData] = useState<{ + email: string; + organization: { id: string; name: string }; + role: string; + user_exists?: boolean; + } | null>(null); - // Mock invite data - will be fetched from URL token - const inviteData = { - email: "invited@example.com", - organization: "Acme Corp", - }; + useEffect(() => { + if (!token) { + setTokenError("No invite token found in the URL."); + setIsTokenLoading(false); + return; + } + api.invites.getInfo(token) + .then((data) => { + setInviteData(data); + }) + .catch(() => { + setTokenError("This invitation link is invalid or has expired."); + }) + .finally(() => setIsTokenLoading(false)); + }, [token]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (password !== confirmPassword) { - return; + setSubmitError(""); + if (!inviteData?.user_exists) { + if (password !== confirmPassword) { + setSubmitError("Passwords do not match."); + return; + } + if (password.length < 8) { + setSubmitError("Password must be at least 8 characters."); + return; + } } setIsLoading(true); - setTimeout(() => { - setIsLoading(false); + try { + const result = await api.invites.accept(token, name || undefined, inviteData?.user_exists ? undefined : password); + if (result.token) { + // Store the token manually since we're not using the normal login flow + localStorage.setItem("gatehouse_token", result.token); + } navigate("/profile"); - }, 1000); + } catch (err: unknown) { + const msg = err instanceof ApiError ? err.message : "Failed to accept invite."; + setSubmitError(msg); + } finally { + setIsLoading(false); + } }; + if (isTokenLoading) { + return ( +
+ +
+ ); + } + + if (tokenError) { + return ( +
+
+
+ +
+

+ Invalid Invitation +

+

{tokenError}

+ + Back to sign in + +
+
+ ); + } + + const isExistingUser = !!inviteData?.user_exists; + return (
@@ -41,95 +111,102 @@ export default function InviteAcceptPage() { You're invited!

- {inviteData.organization} has - invited you to join their organization + {inviteData?.organization.name} has + invited you to join as {inviteData?.role}

- {/* Avatar upload */} -
- - - {name ? name.split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase() : "?"} - - - +
+ +
-
- - -
- -
- -
- - setName(e.target.value)} - className="pl-10" - required - /> + {isExistingUser ? ( +
+ +
+

Account found

+

You already have a Gatehouse account. Click below to join the organization.

+
-
+ ) : ( + <> +
+ +
+ + setName(e.target.value)} + className="pl-10" + required + /> +
+
+
+ +
+ + setPassword(e.target.value)} + className="pl-10" + required + minLength={8} + /> +
+
+
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-10" + required + /> +
+
+ + )} -
- -
- - setPassword(e.target.value)} - className="pl-10" - required - minLength={8} - /> -
-
- -
- -
- - setConfirmPassword(e.target.value)} - className="pl-10" - required - /> -
-
+ {submitError && ( +

+ + {submitError} +

+ )} + + {isExistingUser && ( +

+ Not you?{" "} + + Sign in with a different account + +

+ )}
); diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 13516d0..ecdfdb8 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; -import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } from "lucide-react"; +import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle, Terminal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -49,7 +49,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise(cliRedirectParam); + const cliFetchedRef = useRef(false); + + // Exchange cli_token for the real redirect URL (keeps the URL clean) + useEffect(() => { + if (!cliToken || cliFetchedRef.current) return; + cliFetchedRef.current = true; + fetch(`${GATEHOUSE_API}/cli/redirect-url?token=${encodeURIComponent(cliToken)}`) + .then((r) => r.json()) + .then((body) => { + if (body?.data?.redirect_url) { + setCliRedirectUrl(body.data.redirect_url); + } + }) + .catch(() => {/* ignore — user will just land on normal login */}); + }, [cliToken]); + + const finishCliFlow = useCallback((token: string) => { + if (!cliRedirectUrl) return false; + // cliRedirectUrl already ends with "token=" — just append the value + window.location.href = cliRedirectUrl + encodeURIComponent(token); + return true; + }, [cliRedirectUrl]); + + // If the user is already authenticated and we're in CLI mode, deliver the + // token immediately — no need to show the login form at all. + useEffect(() => { + if (authLoading) return; // wait until auth state is known + if (!cliRedirectUrl) return; + const existingToken = tokenManager.getToken(); + if (user && existingToken) { + finishCliFlow(existingToken); + } + }, [authLoading, user, cliRedirectUrl, finishCliFlow]); + const finishOidcFlow = useCallback(async (token: string) => { if (!oidcSessionId) return false; try { @@ -110,8 +150,12 @@ export default function LoginPage() { e.preventDefault(); setIsLoading(true); + // In CLI or OIDC mode we need to handle post-auth navigation ourselves, + // so tell AuthContext not to navigate to /profile automatically. + const needsCustomNav = !!(cliRedirectUrl || oidcSessionId); + try { - const result = await login(email, password, rememberMe); + const result = await login(email, password, rememberMe, needsCustomNav); if (result.requiresWebAuthn) { setStep('webauthn'); } else if (result.requiresTotp) { @@ -119,13 +163,17 @@ export default function LoginPage() { setTotpCode(""); } else if (result.requiresMfaEnrollment) { // MFA enrollment required - will be handled by ProtectedLayout - // Navigation happens in AuthContext + // Navigation happens in AuthContext (MFA path always navigates) } else if (oidcSessionId) { // OIDC bridge: send token back to the Gatehouse backend to complete the flow const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); + } else if (cliRedirectUrl) { + // CLI bridge: deliver the token directly to the CLI's local server + const token = tokenManager.getToken(); + if (token) finishCliFlow(token); } - // If no TOTP, WebAuthn, or MFA enrollment required, navigation happens in AuthContext + // Normal login: navigation already handled by AuthContext (skipNavigate=false) } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] Login failed:", error); @@ -178,16 +226,22 @@ export default function LoginPage() { // OIDC bridge: finish the flow if this is an OIDC login if (oidcSessionId && response.token) { await finishOidcFlow(response.token); + } else if (cliRedirectUrl && response.token) { + finishCliFlow(response.token); } else { await refreshUser(); navigate('/profile'); } } else { // Fallback to regular TOTP verification - await verifyTotp(totpCode, useBackupCode); + const needsCustomNav = !!(cliRedirectUrl || oidcSessionId); + await verifyTotp(totpCode, useBackupCode, needsCustomNav); if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); + } else if (cliRedirectUrl) { + const token = tokenManager.getToken(); + if (token) finishCliFlow(token); } } } catch (error) { @@ -227,13 +281,17 @@ export default function LoginPage() { setIsLoading(true); try { - await verifyTotp(totpCode, useBackupCode); + const needsCustomNav = !!(cliRedirectUrl || oidcSessionId); + await verifyTotp(totpCode, useBackupCode, needsCustomNav); // OIDC bridge: finish the flow if this is an OIDC login if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); + } else if (cliRedirectUrl) { + const token = tokenManager.getToken(); + if (token) finishCliFlow(token); } - // Otherwise navigation happens in AuthContext + // Normal login: navigation already handled by AuthContext (skipNavigate=false) } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] TOTP verification failed:", error); @@ -292,6 +350,9 @@ export default function LoginPage() { if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); + } else if (cliRedirectUrl) { + const token = tokenManager.getToken(); + if (token) finishCliFlow(token); } else { navigate('/profile'); } @@ -364,6 +425,9 @@ export default function LoginPage() { if (oidcSessionId) { const token = tokenManager.getToken(); if (token) await finishOidcFlow(token); + } else if (cliRedirectUrl) { + const token = tokenManager.getToken(); + if (token) finishCliFlow(token); } else { await refreshUser(); navigate('/profile'); @@ -444,6 +508,11 @@ export default function LoginPage() { ...(oidcSessionId ? { oidc_session_id: oidcSessionId } : {}), }); + // CLI bridge: stash the redirect URL so OAuthCallbackPage can deliver the token + if (cliRedirectUrl) { + sessionStorage.setItem('cli_redirect_url', cliRedirectUrl); + } + // Redirect browser to provider window.location.href = response.authorization_url; @@ -860,11 +929,18 @@ export default function LoginPage() { return (
+ {cliRedirectUrl && ( +
+ +
+ )}

- {oidcSessionId ? "Sign in to continue" : "Welcome back"} + {cliRedirectUrl ? "Authorize CLI access" : oidcSessionId ? "Sign in to continue" : "Welcome back"}

- {oidcSessionId + {cliRedirectUrl + ? "Sign in to grant the Gatehouse CLI access to your account" + : oidcSessionId ? "An application is requesting access to your account" : "Sign in to your account to continue"}

diff --git a/src/pages/auth/OAuthCallbackPage.tsx b/src/pages/auth/OAuthCallbackPage.tsx index 2a629b5..23a42e0 100644 --- a/src/pages/auth/OAuthCallbackPage.tsx +++ b/src/pages/auth/OAuthCallbackPage.tsx @@ -97,6 +97,15 @@ export default function OAuthCallbackPage() { tokenManager.setToken(token, expiresAt); await refreshUser(); + // ── CLI bridge: deliver token to the local CLI server ───────────────── + const cliCallbackUrl = sessionStorage.getItem('cli_redirect_url'); + if (cliCallbackUrl) { + sessionStorage.removeItem('cli_redirect_url'); + // cliCallbackUrl already ends with "token=" — append the value + window.location.href = cliCallbackUrl + encodeURIComponent(token); + return; + } + // ── OIDC bridge: complete the flow and redirect back to the OIDC client ── if (oidcSessionId) { try { diff --git a/src/pages/auth/OIDCConsentPage.tsx b/src/pages/auth/OIDCConsentPage.tsx index 2384dcb..6eb7031 100644 --- a/src/pages/auth/OIDCConsentPage.tsx +++ b/src/pages/auth/OIDCConsentPage.tsx @@ -1,39 +1,122 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { CheckCircle, XCircle, Shield, User, Mail, Building2 } from "lucide-react"; +import { useState, useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { CheckCircle, XCircle, Shield, User, Mail, Building2, Loader2, Key } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { tokenManager } from "@/lib/api"; + +const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; +const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); + +const SCOPE_META: Record = { + openid: { icon: Shield, label: "OpenID", description: "Verify your identity" }, + profile: { icon: User, label: "Profile", description: "Access your name and profile picture" }, + email: { icon: Mail, label: "Email", description: "Access your email address" }, + groups: { icon: Building2, label: "Groups", description: "Access your group memberships" }, + offline_access: { icon: Key, label: "Offline Access", description: "Access your data while you are not logged in" }, +}; + +interface ConsentContext { + oidc_session_id: string; + client_name: string; + scopes: string[]; + redirect_uri: string; +} export default function OIDCConsentPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const oidcSessionId = searchParams.get("oidc_session_id"); + const [isLoading, setIsLoading] = useState(false); + const [context, setContext] = useState(null); + const [fetchError, setFetchError] = useState(null); - // Mock OIDC client data - will be fetched from auth flow - const clientData = { - name: "GitLab", - logo: null, - redirectUri: "https://gitlab.example.com/callback", - scopes: [ - { id: "openid", name: "OpenID", description: "Verify your identity" }, - { id: "profile", name: "Profile", description: "Access your name and profile picture" }, - { id: "email", name: "Email", description: "Access your email address" }, - ], - }; + useEffect(() => { + if (!oidcSessionId) { + setFetchError("No OIDC session provided."); + return; + } - const handleAllow = () => { + (async () => { + try { + const res = await fetch(`${GATEHOUSE_OIDC}/oidc/begin`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oidc_session_id: oidcSessionId }), + }); + const body = await res.json(); + if (!res.ok || !body.success) { + setFetchError(body.message || "Failed to load consent context."); + return; + } + setContext(body.data as ConsentContext); + } catch { + setFetchError("Failed to connect to authentication server."); + } + })(); + }, [oidcSessionId]); + + const handleAllow = async () => { + if (!context) return; setIsLoading(true); - // Mock consent - will redirect to client callback - setTimeout(() => { + try { + const token = tokenManager.getToken(); + if (!token) { + navigate(`/login?oidc_session_id=${context.oidc_session_id}`); + return; + } + const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oidc_session_id: context.oidc_session_id, token }), + }); + const body = await res.json(); + if (!res.ok || !body.success) { + setFetchError(body.message || "Authorization failed."); + return; + } + window.location.href = body.data.redirect_url; + } catch { + setFetchError("Failed to complete authorization."); + } finally { setIsLoading(false); - // In real implementation: redirect to redirectUri with auth code - }, 500); + } }; const handleDeny = () => { - navigate(-1); + if (context?.redirect_uri) { + window.location.href = `${context.redirect_uri}?error=access_denied&error_description=User+denied+access`; + } else { + navigate(-1); + } }; + if (fetchError) { + return ( +
+
+ +
+

Authorization Error

+

{fetchError}

+ +
+ ); + } + + if (!context) { + return ( +
+ +

Loading authorization request…

+
+ ); + } + return (
@@ -41,7 +124,7 @@ export default function OIDCConsentPage() {

- Authorize {clientData.name} + Authorize {context.client_name}

This application wants to access your account @@ -50,31 +133,42 @@ export default function OIDCConsentPage() {

- {clientData.name} is requesting access to: + {context.client_name} is requesting access to:

    - {clientData.scopes.map((scope) => ( -
  • -
    - {scope.id === "openid" && } - {scope.id === "profile" && } - {scope.id === "email" && } -
    -
    -

    {scope.name}

    -

    {scope.description}

    -
    -
  • - ))} + {context.scopes.map((scope) => { + const meta = SCOPE_META[scope]; + const Icon = meta?.icon ?? Key; + return ( +
  • +
    + +
    +
    +

    + {meta?.label ?? scope} +

    +

    + {meta?.description ?? scope} +

    +
    +
  • + ); + })}
-
- - - Redirecting to: {clientData.redirectUri} - -
+ {context.redirect_uri && ( +
+ + + Redirecting to:{" "} + + {new URL(context.redirect_uri).origin} + + +
+ )} @@ -93,8 +187,12 @@ export default function OIDCConsentPage() { className="flex-1" disabled={isLoading} > - - {isLoading ? "Authorizing..." : "Allow"} + {isLoading ? ( + + ) : ( + + )} + {isLoading ? "Authorizing…" : "Allow"}
diff --git a/src/pages/auth/OIDCErrorPage.tsx b/src/pages/auth/OIDCErrorPage.tsx index 7ca5311..ef9db8c 100644 --- a/src/pages/auth/OIDCErrorPage.tsx +++ b/src/pages/auth/OIDCErrorPage.tsx @@ -1,14 +1,26 @@ import { AlertTriangle, ArrowLeft, Home } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; + +const ERROR_DESCRIPTIONS: Record = { + invalid_request: "The request was missing a required parameter or was otherwise malformed.", + unauthorized_client: "The client is not authorized to request an authorization code using this method.", + access_denied: "The resource owner or authorization server denied the request.", + unsupported_response_type: "The authorization server does not support obtaining an authorization code using this method.", + invalid_scope: "The requested scope is invalid, unknown, or malformed.", + server_error: "The authorization server encountered an unexpected condition that prevented it from fulfilling the request.", + temporarily_unavailable: "The authorization server is temporarily unavailable. Please try again later.", +}; export default function OIDCErrorPage() { - // Mock error data - will be parsed from URL params - const errorData = { - error: "invalid_request", - description: "The request was missing a required parameter or was otherwise malformed.", - clientName: "Unknown Application", - }; + const [searchParams] = useSearchParams(); + + const error = searchParams.get("error") || "server_error"; + const errorDescription = + searchParams.get("error_description") || + ERROR_DESCRIPTIONS[error] || + "An unexpected error occurred during authentication."; + const clientName = searchParams.get("client") || "the application"; return (
@@ -20,15 +32,16 @@ export default function OIDCErrorPage() { Authentication Error

- There was a problem with the authentication request. + There was a problem with the authentication request from{" "} + {clientName}.

- Error: {errorData.error} + Error: {error}

- {errorData.description} + {decodeURIComponent(errorDescription)}

diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx index 04bc83e..26b5358 100644 --- a/src/pages/auth/RegisterPage.tsx +++ b/src/pages/auth/RegisterPage.tsx @@ -1,16 +1,16 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { Mail, Lock, User, ArrowRight, ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; import { BannerAlert } from "@/components/auth/BannerAlert"; +import { api, ApiError } from "@/lib/api"; type RegistrationState = "form" | "success" | "disabled"; export default function RegisterPage() { - const navigate = useNavigate(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -42,22 +42,25 @@ export default function RegisterPage() { setIsLoading(true); - // Mock registration - will be replaced with actual API call - // POST /api/auth/register - setTimeout(() => { - setIsLoading(false); - - // Simulate different responses - const mockResponse = "success" as RegistrationState | "error"; - - if (mockResponse === "disabled") { - setState("disabled"); - } else if (mockResponse === "error") { - setError("An error occurred. Please try again."); + try { + await api.auth.register(email, password, name.trim() || undefined); + // Show "check your email" — verification email was sent + setState("success"); + } catch (err) { + if (err instanceof ApiError) { + if (err.code === 409) { + setError("An account with this email already exists."); + } else if (err.code === 403 || (err.message && err.message.toLowerCase().includes("disabled"))) { + setState("disabled"); + } else { + setError(err.message || "An error occurred. Please try again."); + } } else { - setState("success"); + setError("An error occurred. Please try again."); } - }, 1000); + } finally { + setIsLoading(false); + } }; // Registration disabled state diff --git a/src/pages/auth/ResetPasswordPage.tsx b/src/pages/auth/ResetPasswordPage.tsx index 75e2741..77d15c9 100644 --- a/src/pages/auth/ResetPasswordPage.tsx +++ b/src/pages/auth/ResetPasswordPage.tsx @@ -1,27 +1,43 @@ import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Lock, ArrowRight, CheckCircle } from "lucide-react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { Lock, ArrowRight, CheckCircle, AlertCircle, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; export default function ResetPasswordPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") || ""; + const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setError(""); if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + if (!token) { + setError("Invalid reset link. Please request a new one."); return; } setIsLoading(true); - setTimeout(() => { - setIsLoading(false); + try { + await api.auth.resetPassword(token, password); setIsSuccess(true); - }, 1000); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to reset password. The link may be expired."; + setError(msg); + } finally { + setIsLoading(false); + } }; if (isSuccess) { @@ -96,7 +112,7 @@ export default function ResetPasswordPage() { + + {error && ( +

+ + {error} +

+ )}
); diff --git a/src/pages/auth/VerifyEmailPage.tsx b/src/pages/auth/VerifyEmailPage.tsx index d126286..173bccd 100644 --- a/src/pages/auth/VerifyEmailPage.tsx +++ b/src/pages/auth/VerifyEmailPage.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { BannerAlert } from "@/components/auth/BannerAlert"; +import { api, ApiError } from "@/lib/api"; type VerificationState = "verifying" | "success" | "error" | "resend"; @@ -22,21 +23,20 @@ export default function VerifyEmailPage() { if (token && state === "verifying") { verifyToken(token); } - }, [token, state]); + }, [token]); const verifyToken = async (verificationToken: string) => { - // Mock verification - POST /api/auth/verify-email?token=... - setTimeout(() => { - // Simulate different responses - const mockSuccess = Math.random() > 0.3; // 70% success rate for demo - - if (mockSuccess) { - setState("success"); + try { + await api.auth.verifyEmail(verificationToken); + setState("success"); + } catch (err) { + setState("error"); + if (err instanceof ApiError) { + setErrorMessage(err.message || "This verification link has expired or is invalid."); } else { - setState("error"); setErrorMessage("This verification link has expired or is invalid."); } - }, 1500); + } }; const handleResendVerification = async (e: React.FormEvent) => { @@ -44,11 +44,14 @@ export default function VerifyEmailPage() { setIsResending(true); setResendSuccess(false); - // Mock resend - POST /api/auth/request-email-verification - setTimeout(() => { + try { + await api.auth.resendVerification(resendEmail); + } catch { + // Always show success to avoid leaking account existence + } finally { setIsResending(false); setResendSuccess(true); - }, 1000); + } }; // Loading / Verifying state diff --git a/src/pages/org/CompliancePage.tsx b/src/pages/org/CompliancePage.tsx index e6ed660..9138c14 100644 --- a/src/pages/org/CompliancePage.tsx +++ b/src/pages/org/CompliancePage.tsx @@ -1,14 +1,15 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { Shield, Search, Filter, Loader2, User, Clock, AlertTriangle, CheckCircle, Mail, ExternalLink } from "lucide-react"; +import { Shield, Search, Loader2, User, Clock, AlertTriangle, CheckCircle, Mail, ExternalLink } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { api, OrgComplianceMember, create403Handler } from "@/lib/api"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; +import { useOrganizations } from "@/hooks/useOrganizations"; const STATUS_CONFIG: Record = { compliant: { @@ -51,18 +52,13 @@ export default function CompliancePage() { const [statusFilter, setStatusFilter] = useState("all"); // Fetch organizations to get current org - const { data: orgsData, isLoading: orgsLoading } = useQuery({ - queryKey: ['organizations'], - queryFn: () => api.users.organizations({ - on403: create403Handler(toast), - }), - }); + const { data: organizations, isLoading: orgsLoading } = useOrganizations(); useEffect(() => { - if (orgsData?.organizations && orgsData.organizations.length > 0) { - setCurrentOrgId(orgsData.organizations[0].id); + if (organizations && organizations.length > 0) { + setCurrentOrgId(organizations[0].id); } - }, [orgsData]); + }, [organizations]); // Fetch compliance data const { data: complianceData, isLoading: complianceLoading } = useQuery({ @@ -73,6 +69,18 @@ export default function CompliancePage() { enabled: !!currentOrgId, }); + // Send MFA reminder mutation + const { mutate: sendReminder, variables: reminderVars, isPending: isSendingReminder } = useMutation({ + mutationFn: ({ userId }: { userId: string }) => + api.organizations.sendMfaReminder(currentOrgId!, userId), + onSuccess: () => { + toast({ title: "Reminder sent", description: "MFA reminder email sent successfully." }); + }, + onError: () => { + toast({ title: "Failed to send", description: "Could not send the reminder. Please try again.", variant: "destructive" }); + }, + }); + // Filter members based on search and status const filteredMembers = complianceData?.members?.filter((member) => { const matchesSearch = @@ -256,10 +264,8 @@ export default function CompliancePage() { size="icon" className="h-8 w-8" title="Send Reminder" - onClick={() => { - // TODO: Implement send reminder - console.log('Send reminder to', member.user_id); - }} + disabled={isSendingReminder && reminderVars?.userId === member.user_id} + onClick={() => sendReminder({ userId: member.user_id })} > diff --git a/src/pages/org/DepartmentsPage.tsx b/src/pages/org/DepartmentsPage.tsx index f74f4af..bddd25b 100644 --- a/src/pages/org/DepartmentsPage.tsx +++ b/src/pages/org/DepartmentsPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, X } from "lucide-react"; +import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, X, ChevronDown, ChevronUp, ShieldCheck, UserPlus, UserMinus, Link as LinkIcon } from "lucide-react"; import { useParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -21,10 +21,348 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { api, Department, Principal } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api, Department, Principal, DeptCertPolicy, STANDARD_SSH_EXTENSIONS, DepartmentMember, OrganizationMember } from "@/lib/api"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; import { useToast } from "@/hooks/use-toast"; +// --------------------------------------------------------------------------- +// Department Certificate Policy Panel +// --------------------------------------------------------------------------- + +function DepartmentCertPolicyPanel({ orgId, deptId }: { orgId: string; deptId: string }) { + const { toast } = useToast(); + const [policy, setPolicy] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Local editable form state + const [allowUserExpiry, setAllowUserExpiry] = useState(false); + const [defaultExpiry, setDefaultExpiry] = useState(1); + const [maxExpiry, setMaxExpiry] = useState(24); + const [allowedExtensions, setAllowedExtensions] = useState([...STANDARD_SSH_EXTENSIONS]); + const [customExtensions, setCustomExtensions] = useState([]); + const [newCustomExt, setNewCustomExt] = useState(""); + + useEffect(() => { + setIsLoading(true); + api.admin.getDeptCertPolicy(orgId, deptId) + .then((data) => { + const p = data.cert_policy; + setPolicy(p); + setAllowUserExpiry(p.allow_user_expiry); + setDefaultExpiry(p.default_expiry_hours); + setMaxExpiry(p.max_expiry_hours); + setAllowedExtensions(p.allowed_extensions ?? [...STANDARD_SSH_EXTENSIONS]); + setCustomExtensions(p.custom_extensions ?? []); + }) + .catch(() => {/* non-fatal — use defaults */}) + .finally(() => setIsLoading(false)); + }, [orgId, deptId]); + + const toggleExtension = (ext: string) => { + setAllowedExtensions((prev) => + prev.includes(ext) ? prev.filter((e) => e !== ext) : [...prev, ext] + ); + }; + + const addCustomExt = () => { + const trimmed = newCustomExt.trim(); + if (!trimmed || customExtensions.includes(trimmed)) return; + setCustomExtensions((prev) => [...prev, trimmed]); + setNewCustomExt(""); + }; + + const removeCustomExt = (ext: string) => { + setCustomExtensions((prev) => prev.filter((e) => e !== ext)); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + // When members can pick: max_expiry is the cap, default_expiry is also set to max + // (the backend uses default when no expiry is provided). + // When members can't pick: default_expiry is the fixed value, max is irrelevant. + const payload = allowUserExpiry + ? { allow_user_expiry: true, default_expiry_hours: maxExpiry, max_expiry_hours: maxExpiry } + : { allow_user_expiry: false, default_expiry_hours: defaultExpiry, max_expiry_hours: defaultExpiry }; + + const data = await api.admin.setDeptCertPolicy(orgId, deptId, { + ...payload, + allowed_extensions: allowedExtensions, + custom_extensions: customExtensions, + }); + setPolicy(data.cert_policy); + toast({ title: "Policy saved", description: "Certificate policy updated." }); + } catch (err) { + toast({ variant: "destructive", title: "Failed to save policy" }); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+ Loading policy… +
+ ); + } + + return ( +
+

+ + Certificate Policy +

+ + {/* Allow user to choose expiry */} +
+
+

Allow members to pick expiry date

+

Admins can always pick.

+
+ +
+ + {/* Expiry hours — conditional on toggle */} + {allowUserExpiry ? ( +
+ + setMaxExpiry(Math.max(1, Number(e.target.value)))} + className="h-8 text-sm w-40" + /> +

Members can choose any duration up to this cap.

+
+ ) : ( +
+ + setDefaultExpiry(Math.max(1, Number(e.target.value)))} + className="h-8 text-sm w-40" + /> +

Members receive this expiry automatically — no choice shown.

+
+ )} + + {/* Standard extensions */} +
+ +
+ {STANDARD_SSH_EXTENSIONS.map((ext) => ( +
+ toggleExtension(ext)} + className="accent-primary" + /> + +
+ ))} +
+
+ + {/* Custom extensions */} +
+ +
+ setNewCustomExt(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addCustomExt()} + className="h-8 text-sm" + /> + +
+ {customExtensions.length > 0 && ( +
+ {customExtensions.map((ext) => ( + + {ext} + + + ))} +
+ )} +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Department Members Panel +// --------------------------------------------------------------------------- + +function DepartmentMembersPanel({ orgId, deptId }: { orgId: string; deptId: string }) { + const { toast } = useToast(); + const [members, setMembers] = useState([]); + const [orgMembers, setOrgMembers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isAdding, setIsAdding] = useState(false); + const [removingId, setRemovingId] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(""); + + useEffect(() => { + setIsLoading(true); + Promise.all([ + api.organizations.getDepartmentMembers(orgId, deptId), + api.organizations.getMembers(orgId), + ]) + .then(([deptRes, orgRes]) => { + setMembers(deptRes.members || []); + setOrgMembers(orgRes.members || []); + }) + .catch(() => toast({ variant: "destructive", title: "Failed to load members" })) + .finally(() => setIsLoading(false)); + }, [orgId, deptId, toast]); + + const deptUserIds = new Set(members.map((m) => m.user_id)); + const available = orgMembers.filter((m) => !deptUserIds.has(m.user_id)); + + const handleAdd = async () => { + const orgMember = orgMembers.find((m) => m.user_id === selectedUserId); + if (!orgMember?.user?.email) return; + setIsAdding(true); + try { + const res = await api.organizations.addDepartmentMember(orgId, deptId, orgMember.user.email); + setMembers((prev) => [...prev, res.member]); + setSelectedUserId(""); + toast({ title: "Member added", description: `${orgMember.user.full_name || orgMember.user.email} added to department.` }); + } catch (err) { + toast({ variant: "destructive", title: "Failed to add member" }); + } finally { + setIsAdding(false); + } + }; + + const handleRemove = async (userId: string, displayName: string) => { + setRemovingId(userId); + try { + await api.organizations.removeDepartmentMember(orgId, deptId, userId); + setMembers((prev) => prev.filter((m) => m.user_id !== userId)); + toast({ title: "Member removed", description: `${displayName} removed from department.` }); + } catch (err) { + toast({ variant: "destructive", title: "Failed to remove member" }); + } finally { + setRemovingId(null); + } + }; + + if (isLoading) { + return ( +
+ Loading members… +
+ ); + } + + return ( +
+

+ + Department Members + {members.length} +

+ + {/* Existing members */} + {members.length === 0 ? ( +

No members yet.

+ ) : ( +
    + {members.map((m) => { + const name = m.user?.full_name || m.user?.email || m.user_id; + const email = m.user?.email; + const busy = removingId === m.user_id; + return ( +
  • +
    + {name} + {email && name !== email && ( + {email} + )} +
    + +
  • + ); + })} +
+ )} + + {/* Add member */} + {available.length > 0 && ( +
+ + +
+ )} + {available.length === 0 && members.length > 0 && ( +

All org members are already in this department.

+ )} +
+ ); +} + export default function DepartmentsPage() { const params = useParams<{ orgId?: string }>(); const { orgId: fallbackOrgId } = useCurrentOrganizationId(); @@ -33,14 +371,37 @@ export default function DepartmentsPage() { const [search, setSearch] = useState(""); const [departments, setDepartments] = useState([]); + const [principals, setPrincipals] = useState([]); const [linkedPrincipals, setLinkedPrincipals] = useState>({}); const [unlinkingKey, setUnlinkingKey] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false); + const [linkingDept, setLinkingDept] = useState(null); + const [selectedPrincipalId, setSelectedPrincipalId] = useState(""); + const [isLinking, setIsLinking] = useState(false); const [editingDept, setEditingDept] = useState(null); const [formData, setFormData] = useState({ name: "", description: "" }); + const [expandedPolicies, setExpandedPolicies] = useState>(new Set()); + const [expandedMembers, setExpandedMembers] = useState>(new Set()); + + const togglePolicyPanel = (deptId: string) => { + setExpandedPolicies((prev) => { + const next = new Set(prev); + next.has(deptId) ? next.delete(deptId) : next.add(deptId); + return next; + }); + }; + + const toggleMembersPanel = (deptId: string) => { + setExpandedMembers((prev) => { + const next = new Set(prev); + next.has(deptId) ? next.delete(deptId) : next.add(deptId); + return next; + }); + }; const fetchLinkedPrincipals = useCallback(async (currentOrgId: string, deptList: Department[]) => { if (!deptList.length) return; @@ -61,9 +422,13 @@ export default function DepartmentsPage() { try { setIsLoading(true); setError(null); - const response = await api.organizations.getDepartments(currentOrgId); + const [response, principalsRes] = await Promise.all([ + api.organizations.getDepartments(currentOrgId), + api.organizations.getPrincipals(currentOrgId), + ]); const deptList = response.departments || []; setDepartments(deptList); + setPrincipals(principalsRes.principals || []); await fetchLinkedPrincipals(currentOrgId, deptList); } catch (err) { console.error("Failed to fetch departments:", err); @@ -76,6 +441,7 @@ export default function DepartmentsPage() { useEffect(() => { setError(null); setDepartments([]); + setPrincipals([]); setLinkedPrincipals({}); if (!orgId) { setIsLoading(false); @@ -103,6 +469,29 @@ export default function DepartmentsPage() { } }; + const handleLinkPrincipal = async () => { + if (!orgId || !linkingDept || !selectedPrincipalId) return; + setIsLinking(true); + try { + await api.organizations.linkPrincipalToDepartment(orgId, selectedPrincipalId, linkingDept.id); + toast({ title: "Principal linked", description: "Principal linked to department." }); + setLinkingDept(null); + setSelectedPrincipalId(""); + setIsLinkDialogOpen(false); + await fetchDepartments(orgId); + } catch { + toast({ variant: "destructive", title: "Failed to link principal to department" }); + } finally { + setIsLinking(false); + } + }; + + const openLinkDialog = (dept: Department) => { + setLinkingDept(dept); + setSelectedPrincipalId(""); + setIsLinkDialogOpen(true); + }; + const handleCreateDepartment = async () => { if (!orgId || !formData.name.trim()) return; try { @@ -162,6 +551,11 @@ export default function DepartmentsPage() { ); }); + // Principals not yet linked to the dept being linked + const availablePrincipalsToLink = linkingDept + ? principals.filter((p) => !(linkedPrincipals[linkingDept.id] || []).some((lp) => lp.id === p.id)) + : principals; + return (
@@ -260,6 +654,36 @@ export default function DepartmentsPage() {
Created {new Date(dept.created_at).toLocaleDateString()}
+ + {/* Members toggle */} + + + {/* Members panel */} + {orgId && expandedMembers.has(dept.id) && ( + + )} + + {/* Certificate policy toggle */} + + + {/* Certificate policy panel */} + {orgId && expandedPolicies.has(dept.id) && ( + + )}
@@ -272,6 +696,10 @@ export default function DepartmentsPage() { Edit + openLinkDialog(dept)}> + + Link Principal + + + {/* Link Principal to Department Dialog */} + { + if (!open) { setLinkingDept(null); setSelectedPrincipalId(""); } + setIsLinkDialogOpen(open); + }} + > + + + Link Principal to Department + + Link a principal to {linkingDept?.name}. All department members will gain access to the principal. + + +
+
+ + {availablePrincipalsToLink.length === 0 ? ( +

+ {principals.length === 0 + ? "No principals exist yet. Create one on the Principals page." + : "All principals are already linked to this department."} +

+ ) : ( + + )} +
+
+ + + + +
+
); } diff --git a/src/pages/org/MembersPage.tsx b/src/pages/org/MembersPage.tsx index df0e9b9..0326575 100644 --- a/src/pages/org/MembersPage.tsx +++ b/src/pages/org/MembersPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from "react"; -import { Search, Plus, MoreHorizontal, Shield, User, Mail, Clock, Loader2 } from "lucide-react"; +import { Search, Shield, ShieldCheck, Mail, Loader2, Copy, Check, ExternalLink } from "lucide-react"; import { useParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -13,9 +14,27 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { api, OrganizationMember } from "@/lib/api"; +import { useToast } from "@/hooks/use-toast"; +import { api, OrganizationMember, ApiError, OrgInvite } from "@/lib/api"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; +import { useAuth } from "@/contexts/AuthContext"; +import { MoreHorizontal } from "lucide-react"; const getInitials = (name: string | null | undefined): string => { if (!name) return "?"; @@ -27,16 +46,63 @@ const getInitials = (name: string | null | undefined): string => { .slice(0, 2); }; +function RoleBadge({ role }: { role: string }) { + const r = (role || "").toLowerCase(); + if (r === "owner") { + return ( + + Owner + + ); + } + if (r === "admin") { + return ( + + Admin + + ); + } + return ( + + Member + + ); +} + export default function MembersPage() { const params = useParams<{ orgId?: string }>(); const { orgId: fallbackOrgId } = useCurrentOrganizationId(); const orgId = params.orgId || fallbackOrgId; + const { toast } = useToast(); + const { user: currentUser } = useAuth(); const [search, setSearch] = useState(""); const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Invite dialog + const [showInvite, setShowInvite] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [isInviting, setIsInviting] = useState(false); + const [inviteError, setInviteError] = useState(null); + + // Invite link dialog (shown when email is not configured) + const [inviteLink, setInviteLink] = useState(null); + const [inviteLinkEmail, setInviteLinkEmail] = useState(""); + const [linkCopied, setLinkCopied] = useState(false); + + // Change role dialog + const [changeRoleMember, setChangeRoleMember] = useState(null); + const [newRole, setNewRole] = useState("member"); + const [isChangingRole, setIsChangingRole] = useState(false); + + // Pending invites + const [invites, setInvites] = useState([]); + const [isInvitesLoading, setIsInvitesLoading] = useState(false); + const [cancellingInviteId, setCancellingInviteId] = useState(null); + useEffect(() => { setError(null); setMembers([]); @@ -59,7 +125,20 @@ export default function MembersPage() { } }; + const fetchInvites = async () => { + try { + setIsInvitesLoading(true); + const res = await api.organizations.getInvites(orgId); + setInvites(res.invites || []); + } catch (err) { + console.error("Failed to fetch invites:", err); + } finally { + setIsInvitesLoading(false); + } + }; + fetchMembers(); + fetchInvites(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [orgId]); @@ -71,19 +150,87 @@ export default function MembersPage() { ); }); + const handleInvite = async () => { + if (!orgId) return; + setInviteError(null); + if (!inviteEmail.trim()) { + setInviteError("Email is required."); + return; + } + setIsInviting(true); + try { + const res = await api.organizations.createInvite(orgId, inviteEmail.trim(), inviteRole); + const link = res.invite?.invite_link; + setShowInvite(false); + // Refresh invites list + const updated = await api.organizations.getInvites(orgId); + setInvites(updated.invites || []); + if (link) { + // Email delivery not configured — show copyable link as fallback + setInviteLink(link); + setInviteLinkEmail(inviteEmail.trim()); + } else { + // Email was sent successfully + toast({ title: "Invitation sent", description: `Invite email sent to ${inviteEmail.trim()}.` }); + } + setInviteEmail(""); + setInviteRole("member"); + } catch (err) { + setInviteError(err instanceof ApiError ? err.message : "Failed to send invitation."); + } finally { + setIsInviting(false); + } + }; + + const handleChangeRole = async () => { + if (!orgId || !changeRoleMember) return; + setIsChangingRole(true); + try { + const updated = await api.organizations.updateMemberRole(orgId, changeRoleMember.user_id, newRole.toUpperCase()); + setMembers((prev) => + prev.map((m) => (m.id === changeRoleMember.id ? { ...m, role: updated.member.role } : m)) + ); + toast({ + title: "Role updated", + description: `${changeRoleMember.user?.full_name || changeRoleMember.user?.email} is now a ${newRole}.`, + }); + setChangeRoleMember(null); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to update role", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setIsChangingRole(false); + } + }; + + const handleRemoveMember = async (member: OrganizationMember) => { + if (!orgId) return; + try { + await api.organizations.removeMember(orgId, member.user_id); + setMembers((prev) => prev.filter((m) => m.id !== member.id)); + toast({ + title: "Member removed", + description: `${member.user?.full_name || member.user?.email} has been removed.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to remove member", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } + }; + return (
-
-
-

Members

-

- Manage organization members and invitations -

-
- +
+

Members

+

+ Manage organization members and invitations +

@@ -92,7 +239,7 @@ export default function MembersPage() { Members ({members.length}) - Pending Invites (0) + Invitations {invites.length > 0 && {invites.length}} @@ -139,44 +286,40 @@ export default function MembersPage() {

{member.user?.full_name || member.user?.email}

- {member.role === "admin" && ( - - - Admin - - )} - {member.role === "owner" && ( - - - Owner - - )} +

{member.user?.email}

- - - - - - - - View profile - - - - Change role - - - - Remove member - - - + {/* Actions — hide for self and for owners (can't modify owner here) */} + {member.user?.id !== currentUser?.id && (member.role || "").toLowerCase() !== "owner" && ( + + + + + + { + setChangeRoleMember(member); + setNewRole((member.role || "member").toLowerCase()); + }} + > + + Change role + + + handleRemoveMember(member)} + > + Remove member + + + + )}
))}
@@ -186,15 +329,164 @@ export default function MembersPage() { - - -
- No pending invitations -
-
-
+
+ + +
+

Pending invitations

+ {isInvitesLoading ? 'Loading...' : `${invites.length}`} +
+ {isInvitesLoading ? ( +
Loading invites...
+ ) : invites.length === 0 ? ( +
No pending invitations
+ ) : ( +
+ {invites.map((inv) => ( +
+
+
{inv.email}
+
Role: {inv.role} • Expires: {new Date(inv.expires_at).toLocaleString()}
+
+
+ +
+
+ ))} +
+ )} +
+
+ + + +
+ +

Send an invitation

+
+
+
+ + setInviteEmail(e.target.value)} + /> +
+
+ + +
+ {inviteError && ( +

{inviteError}

+ )} + +
+
+
+
+ + {/* ── Invite link dialog (shown when SMTP not configured) ────────────────── */} + { if (!o) { setInviteLink(null); setLinkCopied(false); } }}> + + + + + Share this invite link + + + Email delivery is not configured. Share this link directly with {inviteLinkEmail}. + + +
+
+ {inviteLink} + +
+

+ This link expires in 7 days. The recipient must already have an account or will be prompted to create one. +

+
+ + + +
+
+ + {/* ── Change role dialog ─────────────────────────────────────────────────── */} + { if (!o) setChangeRoleMember(null); }}> + + + Change role + + Update the role for {changeRoleMember?.user?.full_name || changeRoleMember?.user?.email} + + +
+ + +
+ + + + +
+
); } diff --git a/src/pages/org/MyMembershipsPage.tsx b/src/pages/org/MyMembershipsPage.tsx new file mode 100644 index 0000000..05f327b --- /dev/null +++ b/src/pages/org/MyMembershipsPage.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import { Layers, GitBranch, Building2, Loader2, Link } from "lucide-react"; +import { api, MyOrgMembership } from "@/lib/api"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; + +function MembershipsSkeleton() { + return ( +
+ {[1, 2].map((i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +export default function MyMembershipsPage() { + const [orgs, setOrgs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + api.users.getMyMemberships() + .then((res) => setOrgs(res.orgs ?? [])) + .catch(console.error) + .finally(() => setIsLoading(false)); + }, []); + + const totalDepts = orgs.reduce((s, o) => s + o.departments.length, 0); + const totalPrincipals = orgs.reduce((s, o) => s + o.principals.length, 0); + + return ( +
+
+

My Memberships

+

+ Departments and principals you belong to across your organizations +

+
+ + {isLoading ? ( + + ) : orgs.length === 0 ? ( +
+ +

You're not a member of any organizations yet.

+
+ ) : ( +
+ {/* Summary chips */} +
+
+ + {totalDepts} + department{totalDepts !== 1 ? "s" : ""} +
+
+ + {totalPrincipals} + principal{totalPrincipals !== 1 ? "s" : ""} +
+
+ + {orgs.map((org) => ( +
+ {/* Org header */} +
+
+ +
+ {org.org_name} + + {org.role.toLowerCase()} + +
+ +
+ {/* Departments */} +
+
+ + Departments +
+ {org.departments.length === 0 ? ( +

+ Not assigned to any departments. +

+ ) : ( +
    + {org.departments.map((dept) => ( +
  • + +
    +

    {dept.name}

    + {dept.description && ( +

    {dept.description}

    + )} +
    +
  • + ))} +
+ )} +
+ + {/* Principals */} +
+
+ + Principals +
+ {org.principals.length === 0 ? ( +

+ No principals assigned. +

+ ) : ( +
    + {org.principals.map((p) => ( +
  • + +
    +

    {p.name}

    + {p.description && ( +

    {p.description}

    + )} +
    + {p.via_department && ( + + + via dept + + )} +
  • + ))} +
+ )} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/pages/user/ActivityPage.tsx b/src/pages/user/ActivityPage.tsx index cf32b89..09705cd 100644 --- a/src/pages/user/ActivityPage.tsx +++ b/src/pages/user/ActivityPage.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from "react"; -import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw } from "lucide-react"; +import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw, Users } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api, AuditLogEntry } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; // Map audit log action strings to display info const getEventDisplay = (action: string) => { @@ -31,7 +33,9 @@ const getEventDisplay = (action: string) => { }; export default function ActivityPage() { + const { isOrgAdmin } = useAuth(); const [filter, setFilter] = useState("all"); + const [view, setView] = useState<"mine" | "org">("mine"); const [events, setEvents] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); @@ -39,15 +43,18 @@ export default function ActivityPage() { const loadEvents = () => { setIsLoading(true); setError(""); - api.users.auditLogs({ per_page: "50" }) - .then((data) => { - setEvents(data.audit_logs ?? []); - }) + const req = + view === "org" && isOrgAdmin + ? api.admin.getAuditLogs({ per_page: "100" }).then((d) => d.audit_logs ?? []) + : api.users.auditLogs({ per_page: "50" }).then((d) => d.audit_logs ?? []); + + req + .then((logs) => setEvents(logs)) .catch(() => setError("Failed to load activity. Please try again.")) .finally(() => setIsLoading(false)); }; - useEffect(() => { loadEvents(); }, []); + useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -75,12 +82,23 @@ export default function ActivityPage() {

Activity

- Your recent account activity and security events + {view === "org" ? "Organization-wide audit log" : "Your recent account activity and security events"}

-
+
+ {isOrgAdmin && ( + setView(v as "mine" | "org")}> + + My Activity + + + Org Logs + + + + )} setExpiryHours(e.target.value)} - className="w-36" - /> + {deptCertPolicy?.allow_user_expiry ? ( +
+ setExpiryHours(e.target.value)} + className="w-40" + /> +

+ {isAdminMode + ? deptCertPolicy.max_expiry_hours < 8760 + ? <>Capped at {deptCertPolicy.max_expiry_hours}h by department policy. Leave blank for default ({deptCertPolicy.default_expiry_hours}h). + : <>Leave blank to use default ({deptCertPolicy.default_expiry_hours}h). + : <>Max allowed: {deptCertPolicy.max_expiry_hours}h. Leave blank for default ({deptCertPolicy.default_expiry_hours}h). + } +

+
+ ) : deptCertPolicy ? ( +
+ + Expiry set by policy: {deptCertPolicy.default_expiry_hours} hours +
+ ) : ( +
+ setExpiryHours(e.target.value)} + className="w-36" + /> +

Leave blank to use CA default.

+
+ )}
+ + {/* Extensions granted (informational) */} + {deptCertPolicy && deptCertPolicy.all_extensions?.length > 0 && ( +
+ +
+ {deptCertPolicy.all_extensions?.map((ext) => ( + {ext} + ))} +
+
+ )}
) : (