- {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.
+
+ {isSuspending ? : }
+ Restore account
+
+
+ ) : (
+
+
Suspending blocks this user from logging in and requesting SSH certificates.
+
setShowSuspendConfirm(true)}
+ disabled={isSuspending}
+ className="text-red-600 border-red-300 hover:bg-red-50"
+ >
+
+ Suspend account
+
+
+ )}
+
+ )}
+
+ {/* Role management — only if not viewing yourself and user has org_id */}
+ {selectedUser.org_id && selectedUser.id !== currentUser?.id && (
+
+
+
+ Organization Role
+
+
+
+
+
+
+
+ Member
+ Admin
+ Owner
+
+
+ {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.
+
+
+
+ setShowSuspendConfirm(false)} disabled={isSuspending}>
+ Cancel
+
+
+ {isSuspending && }
+ Suspend
+
+
+
+
);
}
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 (
+
+
+
+
+
(e.currentTarget.style.display = "none")}
+ />
+
{p.name}
+ {p.is_configured ? (
+
+ {p.is_enabled ? "Enabled" : "Disabled"}
+
+ ) : (
+
+ Not configured
+
+ )}
+
+
+
openConfig(p)}>
+ {p.is_configured ? (
+ <> Edit>
+ ) : (
+ <> Configure>
+ )}
+
+ {p.is_configured && (
+
setDeleteDialog({ open: true, provider: p })}
+ >
+
+
+ )}
+
+
+
+ {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 ↗
+
+
+
+
+
+
+ Client ID
+ setClientId(e.target.value)}
+ placeholder="Enter Client ID"
+ />
+
+
+
+
+ Client Secret{" "}
+ {configDialog.provider?.is_configured && (
+ (leave blank to keep existing)
+ )}
+
+
+ setClientSecret(e.target.value)}
+ placeholder={configDialog.provider?.is_configured ? "••••••••" : "Enter Client Secret"}
+ className="pr-10"
+ />
+ setShowSecret((v) => !v)}
+ >
+ {showSecret ? : }
+
+
+
+
+
+ Enable this provider
+
+
+
+
+
+ setConfigDialog({ open: false, provider: null })}>
+ Cancel
+
+
+ {configureMutation.isPending && }
+ Save
+
+
+
+
+
+ {/* 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}
);
diff --git a/src/pages/user/SSHKeysPage.tsx b/src/pages/user/SSHKeysPage.tsx
index e3605ed..cb27afd 100644
--- a/src/pages/user/SSHKeysPage.tsx
+++ b/src/pages/user/SSHKeysPage.tsx
@@ -13,6 +13,7 @@ import {
Pencil,
ShieldOff,
Server,
+ Clock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -46,7 +47,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
-import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg } from "@/lib/api";
+import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
@@ -124,6 +125,7 @@ export default function SSHKeysPage() {
const [signError, setSignError] = useState(null);
const [certType, setCertType] = useState<'user' | 'host'>('user');
const [expiryHours, setExpiryHours] = useState('');
+ const [deptCertPolicy, setDeptCertPolicy] = useState(null);
// Principal selection (populated when sign dialog opens)
const [principalOrgs, setPrincipalOrgs] = useState([]);
@@ -314,8 +316,14 @@ export default function SSHKeysPage() {
setIsAdminMode(false);
setCertType('user');
setExpiryHours('');
+ setDeptCertPolicy(null);
setIsLoadingPrincipals(true);
+ // Fetch dept cert policy in parallel
+ api.ssh.getMyDeptCertPolicy().then((data) => {
+ setDeptCertPolicy(data.policy);
+ }).catch(() => {/* non-fatal */});
+
try {
const data = await api.users.myPrincipals();
setPrincipalOrgs(data.orgs);
@@ -961,22 +969,65 @@ cat /tmp/challenge.txt.sig | base64 -w0`}
)}
- {/* Expiry hours override */}
+ {/* Expiry — controlled by dept cert policy */}
-
- Validity (hours){' '}
- (optional — leave blank to use CA default)
+
+
+ Validity (hours)
- 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 && (
+
+
Extensions granted
+
+ {deptCertPolicy.all_extensions?.map((ext) => (
+ {ext}
+ ))}
+
+
+ )}
) : (