Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/screens/api/ApiKeyEditDialog.tsx
T

181 lines
6.6 KiB
TypeScript

// "API-Schluessel bearbeiten" on the shared FormDialog chrome.
//
// The edit route rendered the boilerplate ApiKeyForm inside an OpenModal, so it
// wore the same odd popup shape the create route did — plus a FormGroup footer
// whose delete button raised its own confirm. The fields are the same three the
// action already accepts; rotate and delete sit in their own section below them,
// because both are irreversible in a way that saving a new alias is not.
//
// Presentational: the route owns the submits. The admin API-key routes keep the
// shared ApiKeyForm for now.
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FeatherRefreshCw, FeatherSave, FeatherTrash2 } from "@subframe/core";
import { Alert } from "~/ui/components/Alert";
import { Button } from "~/ui/components/Button";
import { Checkbox } from "~/ui/components/Checkbox";
import { TextInput } from "~/ui/components/TextInput";
import { DateField } from "../tasks/DateField";
import { FormDialog } from "../shared/FormDialog";
export type ApiKeyEditInput = {
alias: string;
/** ISO date (yyyy-mm-dd) or "" for a key that does not expire. */
expires: string;
active: boolean;
};
export type ApiKeyEditData = {
/** Redacted key (prefix…last four) — the raw secret never reaches the client. */
redactedKey: string;
alias: string;
expires: string;
active: boolean;
canUpdate: boolean;
canDelete: boolean;
canRotate: boolean;
};
export function ApiKeyEditDialog({
data,
submitting,
errorMessage,
onSave,
onRotate,
onDelete,
onClose,
}: {
data: ApiKeyEditData;
submitting: boolean;
errorMessage: string | null;
onSave: (input: ApiKeyEditInput) => void;
onRotate: () => void;
onDelete: () => void;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation();
const [alias, setAlias] = useState(data.alias);
const [expires, setExpires] = useState(data.expires);
const [active, setActive] = useState(data.active);
const [confirm, setConfirm] = useState<"rotate" | "delete" | null>(null);
const disabled = !data.canUpdate;
const canSubmit = !disabled && alias.trim().length > 0 && !submitting;
if (confirm) {
const isDelete = confirm === "delete";
return (
<FormDialog
open
onOpenChange={(open: boolean) => (open ? undefined : setConfirm(null))}
size="xs"
uiId={`ui.settings.apiKeys.editDialog.${confirm}Confirm`}
title={isDelete ? t("models.apiKey.deleteTitle") : t("models.apiKey.rotateTitle")}
footer={
<>
<Button className="mobile:w-full" type="button" variant="tertiary" onClick={() => setConfirm(null)}>
{t("shared.cancel")}
</Button>
<Button
className="mobile:w-full"
variant={isDelete ? "destructive" : "primary"}
icon={isDelete ? <FeatherTrash2 /> : <FeatherRefreshCw />}
loading={submitting}
disabled={submitting}
onClick={() => (isDelete ? onDelete() : onRotate())}
data-ui-id={`ui.settings.apiKeys.editDialog.${confirm}Submit`}
>
{isDelete ? t("shared.delete") : t("models.apiKey.rotate")}
</Button>
</>
}
>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">
{isDelete ? t("models.apiKey.deleteBody", { alias: data.alias }) : t("models.apiKey.rotateBody", { alias: data.alias })}
</span>
</FormDialog>
);
}
return (
<FormDialog
open
onOpenChange={(open: boolean) => (open ? undefined : onClose())}
title={t("models.apiKey.editTitle")}
subtitle={data.redactedKey}
uiId="ui.settings.apiKeys.editDialog"
footer={
<>
<Button type="button" variant="tertiary" onClick={onClose} disabled={submitting}>
{t("shared.cancel")}
</Button>
<Button
icon={<FeatherSave />}
loading={submitting}
disabled={!canSubmit}
onClick={() => onSave({ alias: alias.trim(), expires, active })}
data-ui-id="ui.settings.apiKeys.editDialog.submit"
>
{t("shared.save")}
</Button>
</>
}
>
{errorMessage ? <Alert variant="error" title={errorMessage} /> : null}
<TextInput className="w-full max-w-full" label={t("models.apiKey.alias")} showLabel>
<TextInput.Input
value={alias}
onChange={(event) => setAlias(event.target.value)}
disabled={disabled}
autoComplete="off"
required
data-ui-id="ui.settings.apiKeys.editDialog.alias"
/>
</TextInput>
<div className="flex w-full flex-col gap-1">
<DateField label={t("models.apiKey.expires")} name="expires" value={expires} onChange={setExpires} />
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("models.apiKey.expiresHint")}</span>
</div>
<Checkbox className="w-fit" label={t("models.apiKey.active")} checked={active} disabled={disabled} onCheckedChange={(checked: boolean) => setActive(checked)} />
{data.canRotate || data.canDelete ? (
<div className="flex w-full flex-col gap-3 border-t border-solid border-border-default pt-4">
{/* Both are irreversible in a way that renaming a key is not, so they
stand apart from the save action and ask before they run. */}
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-tertiary">{t("models.apiKey.dangerSection")}</span>
<div className="flex flex-wrap items-center gap-2">
{data.canRotate ? (
<Button
type="button"
variant="secondary"
icon={<FeatherRefreshCw />}
disabled={submitting}
onClick={() => setConfirm("rotate")}
data-ui-id="ui.settings.apiKeys.editDialog.rotate"
>
{t("models.apiKey.rotate")}
</Button>
) : null}
{data.canDelete ? (
<Button
type="button"
variant="destructive"
icon={<FeatherTrash2 />}
disabled={submitting}
onClick={() => setConfirm("delete")}
data-ui-id="ui.settings.apiKeys.editDialog.delete"
>
{t("shared.delete")}
</Button>
) : null}
</div>
</div>
) : null}
</FormDialog>
);
}