feat(web): typed confirmations on destructive actions; toasts for API outcomes
Destructive actions were confirmed by a second danger button rendered where the first one had been, so a double click on Remove deleted the thing without the operator ever reading which thing it was. Servers, monitors, secret keys and sign-in providers now go through ConfirmDialog with requireTyped, matching the secret-group delete that already worked this way. Notification channels and vulnerability alert rules get an untyped dialog: both are a name and a URL and are rebuilt in a minute, but neither had any confirmation at all, and both silently stop alerts that nobody misses until an incident goes unannounced. Mutations otherwise succeeded in silence, or reported into whatever inline banner the page happened to own. Two failure modes came of that: a modal that closed on error left the message nowhere to land, and a save that was rejected left the old values on screen looking exactly like a save that worked (/settings had no error path at all). Every mutation now reports through the existing toast context. Errors stay inline where the surface that raised them is still on screen and the message is a correction to make in it: form validation, the cron field, the tag rows, a rejected licence blob, and the workflow designer's autosave, which is a standing condition rather than an event. Everything else toasts. Ad-hoc feedback removed in favour of it: the "Sent!" button labels on the server maintenance tab, the settings "Saved!" flag, the channel test line, and the steps page's notice/error pair.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type AuthProvider, type AuthPreset } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { Field, inputClass } from "./Field";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -42,6 +42,7 @@ function CallbackRow({ url }: { url: string }) {
|
||||
}
|
||||
|
||||
function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: () => void }) {
|
||||
const toast = useToast();
|
||||
const [preset, setPreset] = useState<string>("google");
|
||||
const [name, setName] = useState("");
|
||||
const [issuerInput, setIssuerInput] = useState("");
|
||||
@@ -60,7 +61,11 @@ function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: (
|
||||
client_secret: clientSecret,
|
||||
enabled: true,
|
||||
}),
|
||||
onSuccess: onDone,
|
||||
onSuccess: () => {
|
||||
toast.success(`${name || chosen?.label || "Provider"} added. Register its callback URL with your identity provider before signing in.`);
|
||||
onDone();
|
||||
},
|
||||
// Error stays inline on the form: a rejected issuer is corrected here.
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -116,28 +121,46 @@ function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: (
|
||||
|
||||
function ProviderRow({ p }: { p: AuthProvider }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [secret, setSecret] = useState("");
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["auth-providers"] });
|
||||
|
||||
const { mutate: update, error: updateError } = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof api.updateAuthProvider>[1]) => api.updateAuthProvider(p.provider_id, patch),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, patch) => {
|
||||
setSecret("");
|
||||
invalidate();
|
||||
// The lockout guard answers 409 here — the last way in cannot be
|
||||
// switched off — so the outcome of this toggle is worth stating
|
||||
// rather than leaving to a checkbox that may have sprung back.
|
||||
toast.success(patch.client_secret ? `Client secret updated for ${p.name}.` : patch.enabled ? `${p.name} enabled.` : `${p.name} disabled.`);
|
||||
},
|
||||
});
|
||||
const { mutate: remove, error: deleteError } = useMutation({
|
||||
const {
|
||||
mutate: remove,
|
||||
isPending: removing,
|
||||
error: deleteError,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.deleteAuthProvider(p.provider_id),
|
||||
onSuccess: invalidate,
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(`Removed ${p.name}.`);
|
||||
setConfirming(false);
|
||||
},
|
||||
});
|
||||
const { mutate: test, isPending: testing } = useMutation({
|
||||
mutationFn: () => api.testAuthProvider(p.provider_id),
|
||||
// Stays inline: this one carries a diagnostic worth re-reading against
|
||||
// the fields beside it, which is not what a toast that expires is for.
|
||||
onSuccess: setTestResult,
|
||||
onError: toast.error,
|
||||
});
|
||||
const { mutate: ack } = useMutation({
|
||||
mutationFn: () => api.ackAuthProviderNotice(p.provider_id),
|
||||
onSuccess: invalidate,
|
||||
onError: toast.error,
|
||||
});
|
||||
|
||||
const error = (updateError ?? deleteError) as Error | null;
|
||||
@@ -198,10 +221,36 @@ function ProviderRow({ p }: { p: AuthProvider }) {
|
||||
<Button type="button" variant="ghost" size="sm" loading={testing} onClick={() => test()}>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => remove()}>
|
||||
Remove
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirming(true)}>
|
||||
Remove<span className="sr-only"> {p.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* One click used to remove a working sign-in method for everyone in
|
||||
* the instance. Typed, unlike a channel: the client secret is not
|
||||
* recoverable from here afterwards, so putting this back means
|
||||
* going to the identity provider for a new one.
|
||||
*/}
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Remove sign-in provider"
|
||||
confirmLabel="Remove provider"
|
||||
requireTyped={p.name}
|
||||
loading={removing}
|
||||
error={deleteError ? friendlyMessage(deleteError) : null}
|
||||
onClose={() => setConfirming(false)}
|
||||
onConfirm={() => remove()}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{p.name}</span> is removed and anyone signing in
|
||||
through it loses that route into this instance.
|
||||
</p>
|
||||
<p>The stored client secret goes with it; restoring this provider means issuing a new one.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user