Feat(Fix): Multi Org, Suspension, User Detail
Multi Org switch, members suspend/unsuspend status, delete account, next serial, show email in user member search
This commit is contained in:
+10
-5
@@ -18,6 +18,7 @@ import ResetPasswordPage from "@/pages/auth/ResetPasswordPage";
|
|||||||
import InviteAcceptPage from "@/pages/auth/InviteAcceptPage";
|
import InviteAcceptPage from "@/pages/auth/InviteAcceptPage";
|
||||||
import OIDCConsentPage from "@/pages/auth/OIDCConsentPage";
|
import OIDCConsentPage from "@/pages/auth/OIDCConsentPage";
|
||||||
import OIDCErrorPage from "@/pages/auth/OIDCErrorPage";
|
import OIDCErrorPage from "@/pages/auth/OIDCErrorPage";
|
||||||
|
import OIDCLoginPage from "@/pages/auth/OIDCLoginPage";
|
||||||
import OAuthCallbackPage from "@/pages/auth/OAuthCallbackPage";
|
import OAuthCallbackPage from "@/pages/auth/OAuthCallbackPage";
|
||||||
import ActivatePage from "@/pages/auth/ActivatePage";
|
import ActivatePage from "@/pages/auth/ActivatePage";
|
||||||
|
|
||||||
@@ -40,7 +41,6 @@ import DepartmentsPage from "@/pages/org/DepartmentsPage";
|
|||||||
import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
||||||
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
|
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
|
||||||
import SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
import SystemAuditPage from "@/pages/admin/SystemAuditPage";
|
||||||
import AdminUsersPage from "@/pages/admin/AdminUsersPage";
|
|
||||||
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
|
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
|
||||||
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
|
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
|
||||||
|
|
||||||
@@ -76,17 +76,19 @@ const App = () => (
|
|||||||
|
|
||||||
// Separate component so AuthProvider can use useNavigate
|
// Separate component so AuthProvider can use useNavigate
|
||||||
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { OrgProvider } from "@/contexts/OrgContext";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
|
/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
|
||||||
function GuestRoute({ children }: { children: React.ReactNode }) {
|
function GuestRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isOrgMember, isLoading } = useAuth();
|
const { isAuthenticated, isOrgMember, isLoading } = useAuth();
|
||||||
// Allow authenticated users through to /login when it's a CLI auth request —
|
// Allow authenticated users through to /login when it's a CLI auth request or
|
||||||
// LoginPage will immediately forward the existing token to the CLI callback.
|
// an OIDC session — LoginPage will immediately forward the existing token.
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const isCli = params.has('cli_token') || params.has('cli_redirect');
|
const isCli = params.has('cli_token') || params.has('cli_redirect');
|
||||||
|
const isOidcBridge = params.has('oidc_session_id');
|
||||||
if (isLoading) return null; // wait for auth state to resolve
|
if (isLoading) return null; // wait for auth state to resolve
|
||||||
if (isAuthenticated && !isCli) {
|
if (isAuthenticated && !isCli && !isOidcBridge) {
|
||||||
// If the user hasn't set up an org yet, send them there first
|
// If the user hasn't set up an org yet, send them there first
|
||||||
return <Navigate to={isOrgMember ? "/profile" : "/org-setup"} replace />;
|
return <Navigate to={isOrgMember ? "/profile" : "/org-setup"} replace />;
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<OrgProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Index redirect */}
|
{/* Index redirect */}
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
@@ -136,6 +139,7 @@ function AppRoutes() {
|
|||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route path="/invite" element={<InviteAcceptPage />} />
|
<Route path="/invite" element={<InviteAcceptPage />} />
|
||||||
<Route path="/consent" element={<OIDCConsentPage />} />
|
<Route path="/consent" element={<OIDCConsentPage />} />
|
||||||
|
<Route path="/oidc-login" element={<OIDCLoginPage />} />
|
||||||
<Route path="/error" element={<OIDCErrorPage />} />
|
<Route path="/error" element={<OIDCErrorPage />} />
|
||||||
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
|
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
|
||||||
<Route path="/activate" element={<ActivatePage />} />
|
<Route path="/activate" element={<ActivatePage />} />
|
||||||
@@ -169,7 +173,7 @@ function AppRoutes() {
|
|||||||
|
|
||||||
{/* Admin routes — org admin/owner only */}
|
{/* Admin routes — org admin/owner only */}
|
||||||
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
|
<Route path="/admin/audit" element={<RequireAdmin><SystemAuditPage /></RequireAdmin>} />
|
||||||
<Route path="/admin/users" element={<RequireAdmin><AdminUsersPage /></RequireAdmin>} />
|
<Route path="/admin/users" element={<Navigate to="/org/members" replace />} />
|
||||||
<Route path="/admin/oauth" element={<RequireAdmin><OAuthProvidersPage /></RequireAdmin>} />
|
<Route path="/admin/oauth" element={<RequireAdmin><OAuthProvidersPage /></RequireAdmin>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ function AppRoutes() {
|
|||||||
|
|
||||||
{/* Dev tools - only shown in development */}
|
{/* Dev tools - only shown in development */}
|
||||||
<ApiDevTools />
|
<ApiDevTools />
|
||||||
|
</OrgProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const orgAdminNavItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ title: "Users", url: "/admin/users", icon: Users },
|
|
||||||
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
|
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
|
||||||
{ title: "Org Audit Log", url: "/org/audit", icon: FileText },
|
{ title: "Org Audit Log", url: "/org/audit", icon: FileText },
|
||||||
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Menu, ChevronDown, LogOut, User, Shield, Building2, Loader2 } from "lucide-react";
|
import { Menu, ChevronDown, LogOut, User, Shield, Building2, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,14 +12,14 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { Organization } from "@/lib/api";
|
import { useOrg } from "@/contexts/OrgContext";
|
||||||
import { useOrganizations } from "@/hooks/useOrganizations";
|
import { useOrganizations } from "@/hooks/useOrganizations";
|
||||||
import { ComplianceBanner } from "@/components/auth/ComplianceBanner";
|
import { ComplianceBanner } from "@/components/auth/ComplianceBanner";
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAuthenticated, mfaCompliance, logout } = useAuth();
|
const { user, mfaCompliance, logout } = useAuth();
|
||||||
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
|
const { selectedOrg, selectOrg } = useOrg();
|
||||||
|
|
||||||
// Use React Query hook for organizations with automatic caching and deduplication
|
// Use React Query hook for organizations with automatic caching and deduplication
|
||||||
const { data: organizations = [], isLoading: orgsLoading } = useOrganizations();
|
const { data: organizations = [], isLoading: orgsLoading } = useOrganizations();
|
||||||
@@ -28,13 +27,6 @@ export function TopBar() {
|
|||||||
// Ensure organizations is always an array (defensive check)
|
// Ensure organizations is always an array (defensive check)
|
||||||
const organizationsArray = Array.isArray(organizations) ? organizations : [];
|
const organizationsArray = Array.isArray(organizations) ? organizations : [];
|
||||||
|
|
||||||
// Set initial currentOrg when organizations are loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (organizationsArray.length > 0 && !currentOrg) {
|
|
||||||
setCurrentOrg(organizationsArray[0]);
|
|
||||||
}
|
|
||||||
}, [organizationsArray, currentOrg]);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
};
|
};
|
||||||
@@ -64,7 +56,7 @@ export function TopBar() {
|
|||||||
<Building2 className="w-3.5 h-3.5 text-primary" />
|
<Building2 className="w-3.5 h-3.5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium hidden sm:inline">
|
<span className="text-sm font-medium hidden sm:inline">
|
||||||
{orgsLoading ? "Loading..." : (currentOrg?.name || "No Organization")}
|
{orgsLoading ? "Loading..." : (selectedOrg?.name || "No Organization")}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -86,7 +78,7 @@ export function TopBar() {
|
|||||||
organizationsArray.map((org) => (
|
organizationsArray.map((org) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={org.id}
|
key={org.id}
|
||||||
onClick={() => setCurrentOrg(org)}
|
onClick={() => selectOrg(org)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Organization } from "@/lib/api";
|
||||||
|
import { useOrganizations } from "@/hooks/useOrganizations";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
interface OrgContextType {
|
||||||
|
/** The currently selected organisation (null while loading or no memberships). */
|
||||||
|
selectedOrg: Organization | null;
|
||||||
|
/** Programmatically switch the active organisation and invalidate all org-scoped queries. */
|
||||||
|
selectOrg: (org: Organization) => void;
|
||||||
|
/** Convenience accessor for the selected org's ID. */
|
||||||
|
selectedOrgId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrgContext = createContext<OrgContextType | null>(null);
|
||||||
|
|
||||||
|
export function OrgProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { data: organizations = [] } = useOrganizations();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||||
|
|
||||||
|
// Auto-select the first org once the list arrives (or when the list changes
|
||||||
|
// and the previously selected org is no longer present, e.g. after deletion).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setSelectedOrg(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (organizations.length === 0) return;
|
||||||
|
|
||||||
|
setSelectedOrg((prev) => {
|
||||||
|
// Keep the current selection if it still exists in the updated list.
|
||||||
|
if (prev && organizations.some((o) => o.id === prev.id)) {
|
||||||
|
// Refresh the object in case name/role changed.
|
||||||
|
return organizations.find((o) => o.id === prev.id) ?? prev;
|
||||||
|
}
|
||||||
|
return organizations[0];
|
||||||
|
});
|
||||||
|
}, [organizations, isAuthenticated]);
|
||||||
|
|
||||||
|
const selectOrg = useCallback(
|
||||||
|
(org: Organization) => {
|
||||||
|
setSelectedOrg(org);
|
||||||
|
// Invalidate all organisation-scoped React Query caches so every page
|
||||||
|
// immediately re-fetches data for the newly selected org.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||||
|
// Invalidate any queries keyed by the previous org id. The broadest
|
||||||
|
// approach is to remove all non-organisations queries so pages reload.
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrgContext.Provider
|
||||||
|
value={{ selectedOrg, selectOrg, selectedOrgId: selectedOrg?.id ?? null }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</OrgContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrg(): OrgContextType {
|
||||||
|
const ctx = useContext(OrgContext);
|
||||||
|
if (!ctx) throw new Error("useOrg must be used inside <OrgProvider>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,28 +1,31 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useOrganizations } from "@/hooks/useOrganizations";
|
import { useOrganizations } from "@/hooks/useOrganizations";
|
||||||
|
import { useOrg } from "@/contexts/OrgContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to get the current organization from URL params or first available org.
|
* Custom hook to get the current organization from URL params, or the
|
||||||
* This helps with backward compatibility if routes don't include orgId.
|
* globally-selected org from OrgContext (set via the TopBar org switcher).
|
||||||
*/
|
*/
|
||||||
export function useCurrentOrganization() {
|
export function useCurrentOrganization() {
|
||||||
const params = useParams<{ orgId?: string }>();
|
const params = useParams<{ orgId?: string }>();
|
||||||
const { data: organizations = [], isLoading } = useOrganizations();
|
const { data: organizations = [], isLoading } = useOrganizations();
|
||||||
|
const { selectedOrg } = useOrg();
|
||||||
|
|
||||||
// If orgId is in params, use that
|
// If orgId is in the URL params, use that specific org.
|
||||||
if (params.orgId) {
|
if (params.orgId) {
|
||||||
return {
|
return {
|
||||||
org: organizations.find((org) => org.id === params.orgId) || organizations[0] || null,
|
org: organizations.find((org) => org.id === params.orgId) ?? organizations[0] ?? null,
|
||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, return the first organization (default)
|
// Otherwise use the org selected via the TopBar switcher (falls back to
|
||||||
return { org: organizations[0] || null, isLoading };
|
// organizations[0] when OrgContext hasn't initialised yet).
|
||||||
|
return { org: selectedOrg ?? organizations[0] ?? null, isLoading };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization ID from URL params or first available org.
|
* Get the organization ID from URL params or the globally-selected org.
|
||||||
* Also returns isLoading so callers can distinguish "no org" from "still loading".
|
* Also returns isLoading so callers can distinguish "no org" from "still loading".
|
||||||
*/
|
*/
|
||||||
export function useCurrentOrganizationId(): { orgId: string | null; isLoading: boolean } {
|
export function useCurrentOrganizationId(): { orgId: string | null; isLoading: boolean } {
|
||||||
|
|||||||
@@ -455,6 +455,10 @@ export const api = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Delete the current user's own account (soft delete)
|
||||||
|
deleteMe: (requestConfig?: RequestConfig) =>
|
||||||
|
request<{ message: string }>('/users/me', { method: 'DELETE' }, true, requestConfig),
|
||||||
|
|
||||||
organizations: (requestConfig?: RequestConfig) =>
|
organizations: (requestConfig?: RequestConfig) =>
|
||||||
request<OrganizationsResponse>('/users/me/organizations', {}, true, requestConfig),
|
request<OrganizationsResponse>('/users/me/organizations', {}, true, requestConfig),
|
||||||
|
|
||||||
@@ -548,6 +552,15 @@ export const api = {
|
|||||||
unsuspendUser: (userId: string, requestConfig?: RequestConfig) =>
|
unsuspendUser: (userId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ user: User }>(`/admin/users/${userId}/unsuspend`, { method: 'POST' }, true, requestConfig),
|
request<{ user: User }>(`/admin/users/${userId}/unsuspend`, { method: 'POST' }, true, requestConfig),
|
||||||
|
|
||||||
|
// Permanently delete a user — revokes certs, cascades DB delete, unrecoverable
|
||||||
|
hardDeleteUser: (userId: string, requestConfig?: RequestConfig) =>
|
||||||
|
request<{ deleted_user_id: string; deleted_user_email: string; ssh_keys_deleted: number; certs_revoked: number }>(
|
||||||
|
`/admin/users/${userId}/delete`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ confirm: true }) },
|
||||||
|
true,
|
||||||
|
requestConfig,
|
||||||
|
),
|
||||||
|
|
||||||
// Get the cert policy for a department
|
// Get the cert policy for a department
|
||||||
getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) =>
|
getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, {}, true, requestConfig),
|
request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, {}, true, requestConfig),
|
||||||
@@ -801,6 +814,10 @@ export const api = {
|
|||||||
getById: (orgId: string, requestConfig?: RequestConfig) =>
|
getById: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig),
|
request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig),
|
||||||
|
|
||||||
|
// Delete an organization (owner only; must have no other members)
|
||||||
|
deleteOrganization: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
|
request<{ message: string }>(`/organizations/${orgId}`, { method: 'DELETE' }, true, requestConfig),
|
||||||
|
|
||||||
// Get organization members
|
// Get organization members
|
||||||
getMembers: (orgId: string, requestConfig?: RequestConfig) =>
|
getMembers: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ members: OrganizationMember[]; count: number }>(`/organizations/${orgId}/members`, {}, true, requestConfig),
|
request<{ members: OrganizationMember[]; count: number }>(`/organizations/${orgId}/members`, {}, true, requestConfig),
|
||||||
@@ -976,6 +993,15 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
}, true, requestConfig),
|
}, true, requestConfig),
|
||||||
|
|
||||||
|
// Transfer organization ownership to another member
|
||||||
|
transferOwnership: (orgId: string, newOwnerUserId: string, requestConfig?: RequestConfig) =>
|
||||||
|
request<{ previous_owner: OrganizationMember; new_owner: OrganizationMember }>(
|
||||||
|
`/organizations/${orgId}/transfer-ownership`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ new_owner_user_id: newOwnerUserId }) },
|
||||||
|
true,
|
||||||
|
requestConfig,
|
||||||
|
),
|
||||||
|
|
||||||
// List Certificate Authorities for an org
|
// List Certificate Authorities for an org
|
||||||
getCAs: (orgId: string, requestConfig?: RequestConfig) =>
|
getCAs: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ cas: OrgCA[]; count: number }>(`/organizations/${orgId}/cas`, {}, true, requestConfig),
|
request<{ cas: OrgCA[]; count: number }>(`/organizations/${orgId}/cas`, {}, true, requestConfig),
|
||||||
@@ -1364,6 +1390,8 @@ export interface OrgCA {
|
|||||||
total_certs: number;
|
total_certs: number;
|
||||||
active_certs: number;
|
active_certs: number;
|
||||||
revoked_certs: number;
|
revoked_certs: number;
|
||||||
|
/** Next serial number that will be assigned when a certificate is issued. */
|
||||||
|
next_serial_number: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
/** Set when the key was last rotated. */
|
/** Set when the key was last rotated. */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Ban,
|
Ban,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -123,6 +124,11 @@ export default function AdminUsersPage() {
|
|||||||
const [isSuspending, setIsSuspending] = useState(false);
|
const [isSuspending, setIsSuspending] = useState(false);
|
||||||
const [showSuspendConfirm, setShowSuspendConfirm] = useState(false);
|
const [showSuspendConfirm, setShowSuspendConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Hard delete
|
||||||
|
const [showHardDelete, setShowHardDelete] = useState(false);
|
||||||
|
const [hardDeleteConfirmEmail, setHardDeleteConfirmEmail] = useState("");
|
||||||
|
const [isHardDeleting, setIsHardDeleting] = useState(false);
|
||||||
|
|
||||||
// ── Fetch users ─────────────────────────────────────────────────────────────
|
// ── Fetch users ─────────────────────────────────────────────────────────────
|
||||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -233,7 +239,16 @@ export default function AdminUsersPage() {
|
|||||||
setShowSuspendConfirm(false);
|
setShowSuspendConfirm(false);
|
||||||
toast({ title: "User suspended", description: `${selectedUser.full_name || selectedUser.email} has been suspended.` });
|
toast({ title: "User suspended", description: `${selectedUser.full_name || selectedUser.email} has been suspended.` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setShowSuspendConfirm(false);
|
||||||
|
if (err instanceof ApiError && err.type === "OWNER_PROTECTION") {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Cannot suspend organization owner",
|
||||||
|
description: "Transfer ownership to another member before suspending this account.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" });
|
toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSuspending(false);
|
setIsSuspending(false);
|
||||||
}
|
}
|
||||||
@@ -255,6 +270,31 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Hard delete user ─────────────────────────────────────────────────────────
|
||||||
|
const handleHardDelete = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
setIsHardDeleting(true);
|
||||||
|
try {
|
||||||
|
const result = await api.admin.hardDeleteUser(selectedUser.id);
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id));
|
||||||
|
setTotal((t) => t - 1);
|
||||||
|
setShowHardDelete(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
toast({
|
||||||
|
title: "User permanently deleted",
|
||||||
|
description: `${result.deleted_user_email} — ${result.certs_revoked} cert(s) revoked, ${result.ssh_keys_deleted} key(s) deleted.`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to delete user",
|
||||||
|
description: err instanceof ApiError ? err.message : "Something went wrong",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsHardDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Filter by role client-side
|
// Filter by role client-side
|
||||||
const filteredUsers = users.filter((u) => {
|
const filteredUsers = users.filter((u) => {
|
||||||
if (roleFilter === "all") return true;
|
if (roleFilter === "all") return true;
|
||||||
@@ -540,6 +580,28 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone — Hard delete */}
|
||||||
|
{selectedUser.id !== currentUser?.id && (
|
||||||
|
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Danger Zone
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Permanently delete this account. This cannot be undone — all SSH keys and certificates will be revoked immediately.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setHardDeleteConfirmEmail(""); setShowHardDelete(true); }}
|
||||||
|
className="text-destructive border-destructive/40 hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Permanently delete account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -617,6 +679,55 @@ export default function AdminUsersPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Hard delete confirmation ──────────────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={showHardDelete}
|
||||||
|
onOpenChange={(open) => { setShowHardDelete(open); if (!open) setHardDeleteConfirmEmail(""); }}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Permanently delete account?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will <strong>permanently</strong> delete{" "}
|
||||||
|
<strong>{selectedUser?.full_name || selectedUser?.email}</strong>,
|
||||||
|
revoke all their SSH certificates, and remove all their SSH keys. This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2 space-y-2">
|
||||||
|
<Label className="text-sm">
|
||||||
|
Type <span className="font-mono font-semibold">{selectedUser?.email}</span> to confirm
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={hardDeleteConfirmEmail}
|
||||||
|
onChange={(e) => setHardDeleteConfirmEmail(e.target.value)}
|
||||||
|
placeholder={selectedUser?.email ?? ""}
|
||||||
|
disabled={isHardDeleting}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowHardDelete(false)}
|
||||||
|
disabled={isHardDeleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleHardDelete}
|
||||||
|
disabled={isHardDeleting || hardDeleteConfirmEmail !== selectedUser?.email}
|
||||||
|
>
|
||||||
|
{isHardDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Delete permanently
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* OIDCLoginPage — Standalone OIDC proxy login UI
|
||||||
|
*
|
||||||
|
* Unified entry point for OIDC authorization flows via the Gatehouse OIDC bridge.
|
||||||
|
* Handles:
|
||||||
|
* 1. Unauthenticated users → shows an email/password login form
|
||||||
|
* 2. Already-authenticated users → shows a consent/approval screen directly
|
||||||
|
*
|
||||||
|
* Route: /oidc-login?oidc_session_id=<id>
|
||||||
|
*
|
||||||
|
* Configure your oauth2-proxy / OIDC client's login_url to:
|
||||||
|
* https://<gatehouse-ui>/oidc-login
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
ArrowRight,
|
||||||
|
Loader2,
|
||||||
|
XCircle,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
User,
|
||||||
|
Building2,
|
||||||
|
Key,
|
||||||
|
LogOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { ApiError, tokenManager } from "@/lib/api";
|
||||||
|
|
||||||
|
// ── Configuration ─────────────────────────────────────────────────────────────
|
||||||
|
const GATEHOUSE_OIDC = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1")
|
||||||
|
.replace(/\/api\/v1\/?$/, "");
|
||||||
|
|
||||||
|
// ── Scope display metadata ────────────────────────────────────────────────────
|
||||||
|
const SCOPE_META: Record<string, { icon: typeof Shield; label: string; description: string }> = {
|
||||||
|
openid: { icon: Shield, label: "Identity", description: "Verify your identity" },
|
||||||
|
profile: { icon: User, label: "Profile", description: "Your name and username" },
|
||||||
|
email: { icon: Mail, label: "Email", description: "Your email address" },
|
||||||
|
groups: { icon: Building2, label: "Groups", description: "Your organization memberships" },
|
||||||
|
roles: { icon: Shield, label: "Roles", description: "Your roles in the organization" },
|
||||||
|
offline_access: { icon: Key, label: "Offline Access", description: "Access data while you're away" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
interface OIDCContext {
|
||||||
|
oidc_session_id: string;
|
||||||
|
client_name: string;
|
||||||
|
scopes: string[];
|
||||||
|
redirect_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageStep = "loading" | "login" | "consent" | "error";
|
||||||
|
|
||||||
|
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||||
|
async function fetchOIDCContext(oidcSessionId: string): Promise<OIDCContext> {
|
||||||
|
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) {
|
||||||
|
throw new Error(body.message || "Failed to load authorization context.");
|
||||||
|
}
|
||||||
|
return body.data as OIDCContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeOIDCFlow(oidcSessionId: string, token: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ oidc_session_id: oidcSessionId, token }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok || !body.success) {
|
||||||
|
throw new Error(body.message || "Authorization failed.");
|
||||||
|
}
|
||||||
|
return body.data.redirect_url as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
export default function OIDCLoginPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, isLoading: authLoading, login, logout } = useAuth();
|
||||||
|
|
||||||
|
const oidcSessionId = searchParams.get("oidc_session_id");
|
||||||
|
|
||||||
|
const [step, setStep] = useState<PageStep>("loading");
|
||||||
|
const [context, setContext] = useState<OIDCContext | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [isCompleting, setIsCompleting] = useState(false);
|
||||||
|
|
||||||
|
// Login form state
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Load OIDC context on mount ─────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!oidcSessionId) {
|
||||||
|
setErrorMsg("Missing oidc_session_id. This page must be accessed from an OIDC authorization flow.");
|
||||||
|
setStep("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchOIDCContext(oidcSessionId)
|
||||||
|
.then((ctx) => {
|
||||||
|
setContext(ctx);
|
||||||
|
// Determine initial step once we have context and auth state is known
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
setErrorMsg(err.message);
|
||||||
|
setStep("error");
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [oidcSessionId]);
|
||||||
|
|
||||||
|
// ── Determine step once both context and auth state are ready ───────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || !context || step !== "loading") return;
|
||||||
|
const token = tokenManager.getToken();
|
||||||
|
setStep(token ? "consent" : "login");
|
||||||
|
}, [authLoading, context, step]);
|
||||||
|
|
||||||
|
// ── Complete OIDC flow ──────────────────────────────────────────────────────
|
||||||
|
const handleComplete = useCallback(async () => {
|
||||||
|
if (!context) return;
|
||||||
|
const token = tokenManager.getToken();
|
||||||
|
if (!token) {
|
||||||
|
setStep("login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCompleting(true);
|
||||||
|
try {
|
||||||
|
const redirectUrl = await completeOIDCFlow(context.oidc_session_id, token);
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : "Could not complete authorization.");
|
||||||
|
setStep("error");
|
||||||
|
} finally {
|
||||||
|
setIsCompleting(false);
|
||||||
|
}
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
// ── Login form submit ───────────────────────────────────────────────────────
|
||||||
|
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!context) return;
|
||||||
|
setLoginError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// skipNavigate=true so we control post-login navigation
|
||||||
|
const result = await login(email, password, false, true);
|
||||||
|
if (result.requiresTotp || result.requiresWebAuthn) {
|
||||||
|
// MFA required — hand off to main login page which already handles this
|
||||||
|
navigate(`/login?oidc_session_id=${context.oidc_session_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Login succeeded — move to consent step
|
||||||
|
setStep("consent");
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError ? err.message
|
||||||
|
: err instanceof Error ? err.message
|
||||||
|
: "Invalid email or password.";
|
||||||
|
setLoginError(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Deny / cancel ───────────────────────────────────────────────────────────
|
||||||
|
const handleDeny = () => {
|
||||||
|
if (context?.redirect_uri) {
|
||||||
|
window.location.href = `${context.redirect_uri}?error=access_denied&error_description=User+denied+access`;
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Switch account ──────────────────────────────────────────────────────────
|
||||||
|
const handleSwitchAccount = async () => {
|
||||||
|
await logout();
|
||||||
|
setStep("login");
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setLoginError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render: loading ──────────────────────────────────────────────────────────
|
||||||
|
if (step === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="auth-card text-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-accent animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading authorization request…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: error ─────────────────────────────────────────────────────────────
|
||||||
|
if (step === "error") {
|
||||||
|
return (
|
||||||
|
<div className="auth-card text-center space-y-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-destructive/10 flex items-center justify-center mx-auto">
|
||||||
|
<XCircle className="w-7 h-7 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Authorization Error</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{errorMsg}</p>
|
||||||
|
{context?.redirect_uri && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `${context.redirect_uri}?error=server_error&error_description=${encodeURIComponent(errorMsg ?? "Unknown error")}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Return to application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" className="w-full" onClick={() => navigate("/")}>
|
||||||
|
Go to dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: login form ────────────────────────────────────────────────────────
|
||||||
|
if (step === "login") {
|
||||||
|
return (
|
||||||
|
<div className="auth-card space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-primary/10 flex items-center justify-center mx-auto">
|
||||||
|
<Shield className="w-7 h-7 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground tracking-tight">
|
||||||
|
Sign in to continue
|
||||||
|
</h1>
|
||||||
|
{context && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
<span className="font-medium text-foreground">{context.client_name}</span>
|
||||||
|
{" "}is requesting access to your account
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requested scopes preview */}
|
||||||
|
{context && context.scopes.length > 0 && (
|
||||||
|
<Card className="p-3 bg-secondary/30 border-0">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">This application will access:</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{context.scopes.map((scope) => {
|
||||||
|
const meta = SCOPE_META[scope];
|
||||||
|
return (
|
||||||
|
<Badge key={scope} variant="secondary" className="text-xs gap-1">
|
||||||
|
{meta?.label ?? scope}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Login form */}
|
||||||
|
<form onSubmit={handleLoginSubmit} className="space-y-4">
|
||||||
|
{loginError && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-destructive">{loginError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oidc-email">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="oidc-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="pl-9"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oidc-password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="oidc-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="pl-9"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin mr-2" />Signing in…</>
|
||||||
|
) : (
|
||||||
|
<><ArrowRight className="w-4 h-4 mr-2" />Sign in</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Need an account?{" "}
|
||||||
|
<a href="/register" className="text-primary hover:underline">Register here</a>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Forgot your password?{" "}
|
||||||
|
<a href="/forgot-password" className="text-primary hover:underline">Reset it</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeny}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel and return to application
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: consent screen (user is authenticated) ────────────────────────────
|
||||||
|
if (step === "consent" && context) {
|
||||||
|
return (
|
||||||
|
<div className="auth-card space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-primary/10 flex items-center justify-center mx-auto">
|
||||||
|
<Shield className="w-7 h-7 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground tracking-tight">
|
||||||
|
Authorize {context.client_name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
This application is requesting access to your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signed-in-as banner */}
|
||||||
|
{user && (
|
||||||
|
<Card className="p-3 bg-secondary/30 border-0 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{user.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Signed in</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSwitchAccount}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 flex-shrink-0 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3 h-3" />
|
||||||
|
Switch
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Requested permissions */}
|
||||||
|
<Card className="p-4 bg-secondary/30 border-0">
|
||||||
|
<p className="text-sm font-medium text-foreground mb-3">
|
||||||
|
{context.client_name} is requesting:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{context.scopes.map((scope) => {
|
||||||
|
const meta = SCOPE_META[scope];
|
||||||
|
const Icon = meta?.icon ?? Key;
|
||||||
|
return (
|
||||||
|
<li key={scope} className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-card flex items-center justify-center flex-shrink-0">
|
||||||
|
<Icon className="w-4 h-4 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{meta?.label ?? scope}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{meta?.description ?? scope}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={isCompleting}
|
||||||
|
>
|
||||||
|
{isCompleting ? (
|
||||||
|
<><Loader2 className="w-4 h-4 animate-spin mr-2" />Completing…</>
|
||||||
|
) : (
|
||||||
|
<><CheckCircle className="w-4 h-4 mr-2" />Allow access</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full" onClick={handleDeny} disabled={isCompleting}>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
By allowing, you agree to share the above information with{" "}
|
||||||
|
<span className="font-medium text-foreground">{context.client_name}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -128,7 +128,7 @@ function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Stats — hidden for system CAs (we have no cert records for them) */}
|
{/* Stats — hidden for system CAs (we have no cert records for them) */}
|
||||||
{!isSystem && (
|
{!isSystem && (
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<div className="grid grid-cols-4 gap-3 text-center">
|
||||||
<div className="p-2 bg-muted rounded-lg">
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
<p className="text-lg font-semibold">{ca.active_certs}</p>
|
<p className="text-lg font-semibold">{ca.active_certs}</p>
|
||||||
<p className="text-xs text-muted-foreground">Active certs</p>
|
<p className="text-xs text-muted-foreground">Active certs</p>
|
||||||
@@ -141,6 +141,10 @@ function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
|
|||||||
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
|
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
|
||||||
<p className="text-xs text-muted-foreground">Default validity</p>
|
<p className="text-xs text-muted-foreground">Default validity</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
|
<p className="text-lg font-semibold">{ca.next_serial_number ?? '—'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Next serial</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { api, OrgComplianceMember, create403Handler } from "@/lib/api";
|
import { api, OrgComplianceMember, create403Handler } from "@/lib/api";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useOrganizations } from "@/hooks/useOrganizations";
|
import { useOrg } from "@/contexts/OrgContext";
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
|
||||||
compliant: {
|
compliant: {
|
||||||
@@ -47,19 +47,10 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof
|
|||||||
export default function CompliancePage() {
|
export default function CompliancePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [currentOrgId, setCurrentOrgId] = useState<string | null>(null);
|
const { selectedOrgId: currentOrgId } = useOrg();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
|
||||||
// Fetch organizations to get current org
|
|
||||||
const { data: organizations, isLoading: orgsLoading } = useOrganizations();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (organizations && organizations.length > 0) {
|
|
||||||
setCurrentOrgId(organizations[0].id);
|
|
||||||
}
|
|
||||||
}, [organizations]);
|
|
||||||
|
|
||||||
// Fetch compliance data
|
// Fetch compliance data
|
||||||
const { data: complianceData, isLoading: complianceLoading } = useQuery({
|
const { data: complianceData, isLoading: complianceLoading } = useQuery({
|
||||||
queryKey: ['org-compliance', currentOrgId],
|
queryKey: ['org-compliance', currentOrgId],
|
||||||
@@ -101,7 +92,7 @@ export default function CompliancePage() {
|
|||||||
suspended: complianceData?.members?.filter(m => m.status === 'suspended').length || 0,
|
suspended: complianceData?.members?.filter(m => m.status === 'suspended').length || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (orgsLoading || complianceLoading) {
|
if (complianceLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|||||||
@@ -344,11 +344,18 @@ function DepartmentMembersPanel({ orgId, deptId }: { orgId: string; deptId: stri
|
|||||||
className="flex-1 h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="flex-1 h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">Select org member to add…</option>
|
<option value="">Select org member to add…</option>
|
||||||
{available.map((m) => (
|
{available.map((m) => {
|
||||||
|
const email = m.user?.email || "";
|
||||||
|
const name = m.user?.full_name;
|
||||||
|
const label = name && email
|
||||||
|
? `${name} (${email})`
|
||||||
|
: email || m.user_id;
|
||||||
|
return (
|
||||||
<option key={m.user_id} value={m.user_id}>
|
<option key={m.user_id} value={m.user_id}>
|
||||||
{m.user?.full_name || m.user?.email || m.user_id}
|
{label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
<Button size="sm" onClick={handleAdd} disabled={!selectedUserId || isAdding}>
|
<Button size="sm" onClick={handleAdd} disabled={!selectedUserId || isAdding}>
|
||||||
{isAdding ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <UserPlus className="w-3 h-3 mr-1" />}
|
{isAdding ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <UserPlus className="w-3 h-3 mr-1" />}
|
||||||
|
|||||||
+933
-139
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,11 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api";
|
import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useOrg } from "@/contexts/OrgContext";
|
||||||
|
|
||||||
export default function OIDCClientsPage() {
|
export default function OIDCClientsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [orgId, setOrgId] = useState<string | null>(null);
|
const { selectedOrgId: orgId } = useOrg();
|
||||||
const [clients, setClients] = useState<OIDCClient[]>([]);
|
const [clients, setClients] = useState<OIDCClient[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
@@ -44,15 +45,10 @@ export default function OIDCClientsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.users.organizations()
|
if (!orgId) { setIsLoading(false); return; }
|
||||||
.then((data) => {
|
setIsLoading(true);
|
||||||
if (!data.organizations.length) { setIsLoading(false); return; }
|
loadData(orgId);
|
||||||
const id = data.organizations[0].id;
|
}, [orgId]);
|
||||||
setOrgId(id);
|
|
||||||
loadData(id);
|
|
||||||
})
|
|
||||||
.catch(() => { setIsLoading(false); });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!orgId || !nameRef.current || !urisRef.current) return;
|
if (!orgId || !nameRef.current || !urisRef.current) return;
|
||||||
|
|||||||
@@ -1,33 +1,82 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Building2, Users, Shield, Key, ArrowRight, TrendingUp, Loader2 } from "lucide-react";
|
import { Building2, Users, Shield, Key, ArrowRight, TrendingUp, Loader2, Trash2, AlertTriangle, ArrowLeftRight } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { api, Organization, OIDCClient } from "@/lib/api";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { api, OIDCClient, ApiError } from "@/lib/api";
|
||||||
|
import { useOrg } from "@/contexts/OrgContext";import { useOrganizations } from "@/hooks/useOrganizations";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function OrgOverviewPage() {
|
export default function OrgOverviewPage() {
|
||||||
const [org, setOrg] = useState<Organization | null>(null);
|
const navigate = useNavigate();
|
||||||
|
const { selectedOrg, selectOrg } = useOrg();
|
||||||
|
const { refetch: refetchOrgs } = useOrganizations();
|
||||||
const [memberCount, setMemberCount] = useState<number>(0);
|
const [memberCount, setMemberCount] = useState<number>(0);
|
||||||
const [clientCount, setClientCount] = useState<number>(0);
|
const [clientCount, setClientCount] = useState<number>(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Delete org dialog state
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = selectedOrg?.role === "owner";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.users.organizations()
|
if (!selectedOrg) return;
|
||||||
.then(async (data) => {
|
setIsLoading(true);
|
||||||
if (!data.organizations.length) return;
|
Promise.allSettled([
|
||||||
const first = data.organizations[0];
|
api.organizations.getMembers(selectedOrg.id),
|
||||||
setOrg(first);
|
api.organizations.getClients(selectedOrg.id),
|
||||||
|
]).then(([membersResp, clientsResp]) => {
|
||||||
const [membersResp, clientsResp] = await Promise.allSettled([
|
|
||||||
api.organizations.getMembers(first.id),
|
|
||||||
api.organizations.getClients(first.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (membersResp.status === "fulfilled") setMemberCount(membersResp.value.count);
|
if (membersResp.status === "fulfilled") setMemberCount(membersResp.value.count);
|
||||||
if (clientsResp.status === "fulfilled") setClientCount((clientsResp.value as { clients: OIDCClient[]; count: number }).count);
|
if (clientsResp.status === "fulfilled") setClientCount((clientsResp.value as { clients: OIDCClient[]; count: number }).count);
|
||||||
})
|
}).catch(console.error)
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, [selectedOrg?.id]);
|
||||||
|
|
||||||
|
const handleDeleteOrg = async () => {
|
||||||
|
if (!selectedOrg) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.organizations.deleteOrganization(selectedOrg.id);
|
||||||
|
toast({ title: "Organization deleted", description: `"${selectedOrg.name}" has been deleted.` });
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
// Refresh org list; context will auto-select next available org
|
||||||
|
const result = await refetchOrgs();
|
||||||
|
const remaining = result.data ?? [];
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
selectOrg(remaining[0]);
|
||||||
|
navigate("/org");
|
||||||
|
} else {
|
||||||
|
navigate("/org-setup");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.type === "ORG_HAS_MEMBERS") {
|
||||||
|
toast({
|
||||||
|
title: "Cannot delete organization",
|
||||||
|
description: "This organization still has other members. Transfer ownership or remove all members first.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Deletion failed",
|
||||||
|
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{
|
{
|
||||||
@@ -50,7 +99,7 @@ export default function OrgOverviewPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && !selectedOrg) {
|
||||||
return (
|
return (
|
||||||
<div className="page-container flex items-center justify-center py-12">
|
<div className="page-container flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
@@ -58,6 +107,7 @@ export default function OrgOverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const org = selectedOrg;
|
||||||
const createdAt = org?.created_at
|
const createdAt = org?.created_at
|
||||||
? new Date(org.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
? new Date(org.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||||
: "";
|
: "";
|
||||||
@@ -115,7 +165,7 @@ export default function OrgOverviewPage() {
|
|||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||||
{quickLinks.map((link) => (
|
{quickLinks.map((link) => (
|
||||||
<Link key={link.href} to={link.href}>
|
<Link key={link.href} to={link.href}>
|
||||||
<Card className="h-full hover:border-accent/50 transition-colors cursor-pointer group">
|
<Card className="h-full hover:border-accent/50 transition-colors cursor-pointer group">
|
||||||
@@ -133,6 +183,92 @@ export default function OrgOverviewPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone — owners only */}
|
||||||
|
{isOwner && (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-destructive flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Irreversible actions for this organization. Proceed with caution.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Transfer ownership hint */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Transfer Ownership</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Pass ownership to another member before deleting the organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate("/org/members")}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="w-4 h-4 mr-2" />
|
||||||
|
Go to Members
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete organization */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive">Delete Organization</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Permanently deletes this organization.{" "}
|
||||||
|
{memberCount > 1
|
||||||
|
? `You must remove all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""} first.`
|
||||||
|
: "This action cannot be undone."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
disabled={memberCount > 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Delete "{org?.name}"?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently delete the organization and all associated
|
||||||
|
data. This action <strong>cannot be undone</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="w-4 h-4 inline mr-2" />
|
||||||
|
You are about to delete <strong>{org?.name}</strong>. All settings,
|
||||||
|
policies, OIDC clients, and CA configurations will be lost.
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteOrg} disabled={isDeleting}>
|
||||||
|
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Yes, delete organization
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api";
|
import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useOrganizations } from "@/hooks/useOrganizations";
|
import { useOrg } from "@/contexts/OrgContext";
|
||||||
|
|
||||||
const MFA_MODE_LABELS: Record<string, { label: string; description: string }> = {
|
const MFA_MODE_LABELS: Record<string, { label: string; description: string }> = {
|
||||||
disabled: {
|
disabled: {
|
||||||
@@ -41,7 +41,7 @@ export default function PoliciesPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [currentOrgId, setCurrentOrgId] = useState<string | null>(null);
|
const { selectedOrgId: currentOrgId } = useOrg();
|
||||||
|
|
||||||
// Local form state for unsaved changes
|
// Local form state for unsaved changes
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -51,15 +51,6 @@ export default function PoliciesPage() {
|
|||||||
});
|
});
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
// Fetch organizations to get current org
|
|
||||||
const { data: organizations, isLoading: orgsLoading } = useOrganizations();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (organizations && organizations.length > 0) {
|
|
||||||
setCurrentOrgId(organizations[0].id);
|
|
||||||
}
|
|
||||||
}, [organizations]);
|
|
||||||
|
|
||||||
// Fetch org policy
|
// Fetch org policy
|
||||||
const { data: policy, isLoading: policyLoading } = useQuery({
|
const { data: policy, isLoading: policyLoading } = useQuery({
|
||||||
queryKey: ['org-policy', currentOrgId],
|
queryKey: ['org-policy', currentOrgId],
|
||||||
@@ -180,7 +171,7 @@ export default function PoliciesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (orgsLoading || policyLoading) {
|
if (policyLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Mail, Upload, CheckCircle, AlertCircle, Loader2, Bell } from "lucide-react";
|
import { Mail, Upload, CheckCircle, AlertCircle, Loader2, Bell, AlertTriangle, Trash2, Building2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -7,9 +7,18 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { api, ApiError, PendingInvite } from "@/lib/api";
|
import { api, ApiError, PendingInvite } from "@/lib/api";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
function ProfileSkeleton() {
|
function ProfileSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -57,12 +66,17 @@ function ProfileSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, isLoading: authLoading, refreshUser } = useAuth();
|
const { user, isLoading: authLoading, refreshUser, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>([]);
|
const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>([]);
|
||||||
|
|
||||||
|
// Delete account dialog state
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// Sync local name state with user data
|
// Sync local name state with user data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.full_name) {
|
if (user?.full_name) {
|
||||||
@@ -88,6 +102,35 @@ export default function ProfilePage() {
|
|||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.users.deleteMe();
|
||||||
|
toast({ title: "Account deleted", description: "Your account has been deleted." });
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.type === "USER_IS_SOLE_OWNER") {
|
||||||
|
const orgs: string[] = (err.details?.organizations as string[]) ?? [];
|
||||||
|
toast({
|
||||||
|
title: "Cannot delete account",
|
||||||
|
description: `You are the sole owner of: ${orgs.join(", ")}. Transfer ownership or delete those organizations first.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Deletion failed",
|
||||||
|
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
toast({
|
toast({
|
||||||
@@ -139,6 +182,20 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Account Suspended Banner */}
|
||||||
|
{(user.status === "suspended" || user.status === "compliance_suspended") && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 px-4 py-4 text-red-800 dark:border-red-700 dark:bg-red-950/60 dark:text-red-300">
|
||||||
|
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">Account suspended</p>
|
||||||
|
<p className="text-sm mt-0.5 opacity-90">
|
||||||
|
Your account has been suspended. You cannot perform most actions.
|
||||||
|
Please contact your administrator to resolve this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pending Invitations Banner */}
|
{/* Pending Invitations Banner */}
|
||||||
{pendingInvites.length > 0 && (
|
{pendingInvites.length > 0 && (
|
||||||
<div className="rounded-lg border border-primary/40 bg-primary/10 p-4 space-y-3">
|
<div className="rounded-lg border border-primary/40 bg-primary/10 p-4 space-y-3">
|
||||||
@@ -250,7 +307,75 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-destructive flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Irreversible actions for your account. Proceed with caution.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Permanently deletes your profile and all associated data. If you own
|
||||||
|
any organizations you must transfer ownership or delete them first.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete account confirmation dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Delete your account?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Your profile, SSH keys, linked accounts, and session data will be
|
||||||
|
permanently deleted. This action <strong>cannot be undone</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 text-sm text-amber-800 dark:text-amber-300 space-y-1">
|
||||||
|
<p className="flex items-center gap-2 font-medium">
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
Organization ownership check
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you are the sole owner of any organization that has other members,
|
||||||
|
you must <strong>transfer ownership</strong> to another member or delete
|
||||||
|
those organizations before proceeding.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteAccount} disabled={isDeleting}>
|
||||||
|
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Yes, delete my account
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user