All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
493 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|