Files
stack/apps/web/src/app/(authenticated)/settings/auth/page.tsx
Jason Woltje 66d401461c
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(web): fleet settings UI (MS22-P1h) (#617)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 16:22:22 +00:00

493 lines
17 KiB
TypeScript

"use client";
import {
useCallback,
useEffect,
useState,
type ChangeEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
deleteFleetOidcConfig,
fetchFleetOidcConfig,
resetBreakglassAdminPassword,
updateFleetOidcConfig,
type FleetOidcConfig,
} from "@/lib/api/fleet-settings";
import { fetchOnboardingStatus } from "@/lib/api/onboarding";
interface OidcFormState {
issuerUrl: string;
clientId: string;
clientSecret: string;
}
interface BreakglassFormState {
username: string;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
const INITIAL_OIDC_FORM: OidcFormState = {
issuerUrl: "",
clientId: "",
clientSecret: "",
};
const INITIAL_BREAKGLASS_FORM: BreakglassFormState = {
username: "",
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function isAdminGuardError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const normalized = error.message.toLowerCase();
return (
normalized.includes("requires system administrator") ||
normalized.includes("forbidden") ||
normalized.includes("403")
);
}
export default function AuthSettingsPage(): ReactElement {
const [oidcConfig, setOidcConfig] = useState<FleetOidcConfig | null>(null);
const [oidcForm, setOidcForm] = useState<OidcFormState>(INITIAL_OIDC_FORM);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSavingOidc, setIsSavingOidc] = useState<boolean>(false);
const [isDeletingOidc, setIsDeletingOidc] = useState<boolean>(false);
const [oidcError, setOidcError] = useState<string | null>(null);
const [oidcSuccessMessage, setOidcSuccessMessage] = useState<string | null>(null);
const [showRemoveOidcDialog, setShowRemoveOidcDialog] = useState<boolean>(false);
const [breakglassForm, setBreakglassForm] =
useState<BreakglassFormState>(INITIAL_BREAKGLASS_FORM);
const [breakglassStatus, setBreakglassStatus] = useState<"active" | "inactive">("inactive");
const [isResettingPassword, setIsResettingPassword] = useState<boolean>(false);
const [breakglassError, setBreakglassError] = useState<string | null>(null);
const [breakglassSuccessMessage, setBreakglassSuccessMessage] = useState<string | null>(null);
const [isAccessDenied, setIsAccessDenied] = useState<boolean>(false);
const loadAuthSettings = useCallback(async (): Promise<void> => {
setIsLoading(true);
try {
const [oidcResponse, onboardingStatus] = await Promise.all([
fetchFleetOidcConfig(),
fetchOnboardingStatus().catch(() => ({ completed: false })),
]);
setOidcConfig(oidcResponse);
setOidcForm({
issuerUrl: oidcResponse.issuerUrl ?? "",
clientId: oidcResponse.clientId ?? "",
clientSecret: "",
});
setBreakglassStatus(onboardingStatus.completed ? "active" : "inactive");
setIsAccessDenied(false);
setOidcError(null);
} catch (loadError: unknown) {
if (isAdminGuardError(loadError)) {
setIsAccessDenied(true);
return;
}
setOidcError(getErrorMessage(loadError, "Failed to load authentication settings."));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadAuthSettings();
}, [loadAuthSettings]);
async function handleSaveOidc(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setOidcError(null);
setOidcSuccessMessage(null);
const issuerUrl = oidcForm.issuerUrl.trim();
const clientId = oidcForm.clientId.trim();
const clientSecret = oidcForm.clientSecret.trim();
if (issuerUrl.length === 0 || clientId.length === 0 || clientSecret.length === 0) {
setOidcError("Issuer URL, client ID, and client secret are required.");
return;
}
try {
setIsSavingOidc(true);
await updateFleetOidcConfig({
issuerUrl,
clientId,
clientSecret,
});
setOidcSuccessMessage("OIDC configuration updated.");
await loadAuthSettings();
} catch (saveError: unknown) {
setOidcError(getErrorMessage(saveError, "Failed to update OIDC configuration."));
} finally {
setIsSavingOidc(false);
}
}
async function handleRemoveOidc(): Promise<void> {
try {
setIsDeletingOidc(true);
await deleteFleetOidcConfig();
setOidcSuccessMessage("OIDC configuration removed.");
setShowRemoveOidcDialog(false);
await loadAuthSettings();
} catch (deleteError: unknown) {
setOidcError(getErrorMessage(deleteError, "Failed to remove OIDC configuration."));
} finally {
setIsDeletingOidc(false);
}
}
async function handleResetBreakglassPassword(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setBreakglassError(null);
setBreakglassSuccessMessage(null);
const username = breakglassForm.username.trim();
const newPassword = breakglassForm.newPassword;
const confirmPassword = breakglassForm.confirmPassword;
if (username.length === 0) {
setBreakglassError("Username is required.");
return;
}
if (newPassword.length < 8) {
setBreakglassError("New password must be at least 8 characters.");
return;
}
if (newPassword !== confirmPassword) {
setBreakglassError("Password confirmation does not match.");
return;
}
try {
setIsResettingPassword(true);
await resetBreakglassAdminPassword({
username,
newPassword,
});
setBreakglassSuccessMessage(`Password reset for "${username}".`);
setBreakglassStatus("active");
setBreakglassForm((previous) => ({
...previous,
currentPassword: "",
newPassword: "",
confirmPassword: "",
}));
} catch (resetError: unknown) {
setBreakglassError(getErrorMessage(resetError, "Failed to reset breakglass password."));
} finally {
setIsResettingPassword(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="space-y-4">
<div>
<h1 className="text-3xl font-bold">Authentication Settings</h1>
<p className="text-muted-foreground mt-1">
Configure OIDC and breakglass admin recovery credentials.
</p>
</div>
<FleetSettingsNav />
</div>
{isLoading ? (
<Card>
<CardContent className="py-8 text-sm text-muted-foreground">
Loading authentication settings...
</CardContent>
</Card>
) : null}
{!isLoading && isAccessDenied ? (
<SettingsAccessDenied message="Authentication settings require system administrator privileges." />
) : null}
{!isLoading && !isAccessDenied ? (
<>
<Card>
<CardHeader>
<CardTitle>OIDC Provider</CardTitle>
<CardDescription>
Manage your OpenID Connect issuer and OAuth client credentials.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-2">
<p className="font-medium">Configured</p>
<Badge variant={oidcConfig?.configured ? "default" : "secondary"}>
{oidcConfig?.configured ? "Yes" : "No"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Issuer URL: {oidcConfig?.issuerUrl ?? "Not configured"}
</p>
<p className="text-sm text-muted-foreground">
Client ID: {oidcConfig?.clientId ?? "Not configured"}
</p>
<p className="text-sm text-muted-foreground">Client secret: hidden</p>
</div>
<form onSubmit={(event) => void handleSaveOidc(event)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="oidc-issuer-url">Issuer URL</Label>
<Input
id="oidc-issuer-url"
value={oidcForm.issuerUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setOidcForm((previous) => ({ ...previous, issuerUrl: event.target.value }));
}}
placeholder="https://issuer.example.com"
disabled={isSavingOidc}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="oidc-client-id">Client ID</Label>
<Input
id="oidc-client-id"
value={oidcForm.clientId}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setOidcForm((previous) => ({ ...previous, clientId: event.target.value }));
}}
placeholder="mosaic-web"
disabled={isSavingOidc}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="oidc-client-secret">Client Secret</Label>
<Input
id="oidc-client-secret"
type="password"
value={oidcForm.clientSecret}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setOidcForm((previous) => ({
...previous,
clientSecret: event.target.value,
}));
}}
placeholder="Enter new secret"
autoComplete="new-password"
disabled={isSavingOidc}
required
/>
<p className="text-xs text-muted-foreground">
The secret is encrypted on save and never returned to the UI.
</p>
</div>
{oidcError ? (
<p className="text-sm text-destructive" role="alert">
{oidcError}
</p>
) : null}
{oidcSuccessMessage ? (
<p className="text-sm text-emerald-600">{oidcSuccessMessage}</p>
) : null}
<div className="flex items-center gap-2">
<Button type="submit" disabled={isSavingOidc}>
{isSavingOidc ? "Saving..." : "Save OIDC"}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => {
setShowRemoveOidcDialog(true);
}}
disabled={isDeletingOidc || !oidcConfig?.configured}
>
Remove OIDC
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Breakglass Admin</CardTitle>
<CardDescription>
Reset breakglass credentials for emergency local access.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center gap-2">
<p className="font-medium">Status</p>
<Badge variant={breakglassStatus === "active" ? "default" : "secondary"}>
{breakglassStatus}
</Badge>
</div>
<form
onSubmit={(event) => void handleResetBreakglassPassword(event)}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="breakglass-username">Username</Label>
<Input
id="breakglass-username"
value={breakglassForm.username}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
username: event.target.value,
}));
}}
placeholder="admin"
disabled={isResettingPassword}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="breakglass-current-password">Current Password (optional)</Label>
<Input
id="breakglass-current-password"
type="password"
value={breakglassForm.currentPassword}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
currentPassword: event.target.value,
}));
}}
placeholder="Optional operator confirmation"
autoComplete="current-password"
disabled={isResettingPassword}
/>
</div>
<div className="space-y-2">
<Label htmlFor="breakglass-new-password">New Password</Label>
<Input
id="breakglass-new-password"
type="password"
value={breakglassForm.newPassword}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
newPassword: event.target.value,
}));
}}
placeholder="At least 8 characters"
autoComplete="new-password"
disabled={isResettingPassword}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="breakglass-confirm-password">Confirm Password</Label>
<Input
id="breakglass-confirm-password"
type="password"
value={breakglassForm.confirmPassword}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
confirmPassword: event.target.value,
}));
}}
placeholder="Re-enter password"
autoComplete="new-password"
disabled={isResettingPassword}
required
/>
</div>
{breakglassError ? (
<p className="text-sm text-destructive" role="alert">
{breakglassError}
</p>
) : null}
{breakglassSuccessMessage ? (
<p className="text-sm text-emerald-600">{breakglassSuccessMessage}</p>
) : null}
<Button type="submit" disabled={isResettingPassword}>
{isResettingPassword ? "Resetting..." : "Reset Password"}
</Button>
</form>
</CardContent>
</Card>
</>
) : null}
<AlertDialog
open={showRemoveOidcDialog}
onOpenChange={(open) => {
if (!open && !isDeletingOidc) {
setShowRemoveOidcDialog(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove OIDC Configuration</AlertDialogTitle>
<AlertDialogDescription>
This will remove issuer URL, client ID, and client secret from system configuration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingOidc}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleRemoveOidc} disabled={isDeletingOidc}>
{isDeletingOidc ? "Removing..." : "Remove OIDC"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}