clean up a few save buttons

This commit is contained in:
miloschwartz
2025-06-30 12:28:27 -07:00
parent a0381eb2c6
commit 4ffdd6f74f
8 changed files with 282 additions and 287 deletions

View File

@@ -206,6 +206,7 @@
"orgGeneralSettings": "Organization Settings", "orgGeneralSettings": "Organization Settings",
"orgGeneralSettingsDescription": "Manage your organization details and configuration", "orgGeneralSettingsDescription": "Manage your organization details and configuration",
"saveGeneralSettings": "Save General Settings", "saveGeneralSettings": "Save General Settings",
"saveSettings": "Save Settings",
"orgDangerZone": "Danger Zone", "orgDangerZone": "Danger Zone",
"orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.", "orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.",
"orgDelete": "Delete Organization", "orgDelete": "Delete Organization",

View File

@@ -147,6 +147,14 @@ export default function Page() {
} }
}, [userType, env.email.emailEnabled, internalForm, externalForm]); }, [userType, env.email.emailEnabled, internalForm, externalForm]);
const userTypes: UserTypeOption[] = [
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription")
}
];
useEffect(() => { useEffect(() => {
if (!userType) { if (!userType) {
return; return;
@@ -193,6 +201,14 @@ export default function Page() {
if (res?.status === 200) { if (res?.status === 200) {
setIdps(res.data.data.idps); setIdps(res.data.data.idps);
setDataLoaded(true); setDataLoaded(true);
if (res.data.data.idps.length) {
userTypes.push({
id: "oidc",
title: t("userTypeExternal"),
description: t("userTypeExternalDescription")
});
}
} }
} }
@@ -288,19 +304,6 @@ export default function Page() {
setLoading(false); setLoading(false);
} }
const userTypes: ReadonlyArray<UserTypeOption> = [
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription")
},
{
id: "oidc",
title: t("userTypeExternal"),
description: t("userTypeExternalDescription")
}
];
return ( return (
<> <>
<div className="flex justify-between"> <div className="flex justify-between">
@@ -320,7 +323,7 @@ export default function Page() {
<div> <div>
<SettingsContainer> <SettingsContainer>
{!inviteLink && ( {!inviteLink && userTypes.length > 1 ? (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -347,7 +350,7 @@ export default function Page() {
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} ) : null}
{userType === "internal" && dataLoaded && ( {userType === "internal" && dataLoaded && (
<> <>
@@ -496,7 +499,9 @@ export default function Page() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="send-email" id="send-email"
checked={sendEmail} checked={
sendEmail
}
onCheckedChange={( onCheckedChange={(
e e
) => ) =>
@@ -528,7 +533,9 @@ export default function Page() {
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{sendEmail {sendEmail
? t("inviteEmailSentDescription") ? t(
"inviteEmailSentDescription"
)
: t("inviteSentDescription")} : t("inviteSentDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
@@ -778,7 +785,14 @@ export default function Page() {
form={inviteLink ? undefined : "create-user-form"} form={inviteLink ? undefined : "create-user-form"}
loading={loading} loading={loading}
disabled={loading} disabled={loading}
onClick={inviteLink ? () => router.push(`/${orgId}/settings/access/users`) : undefined} onClick={
inviteLink
? () =>
router.push(
`/${orgId}/settings/access/users`
)
: undefined
}
> >
{inviteLink ? t("done") : t("accessUserCreate")} {inviteLink ? t("done") : t("accessUserCreate")}
</Button> </Button>

View File

@@ -102,6 +102,7 @@ export default function GeneralForm() {
const GeneralFormSchema = z const GeneralFormSchema = z
.object({ .object({
enabled: z.boolean(),
subdomain: z.string().optional(), subdomain: z.string().optional(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
proxyPort: z.number().optional(), proxyPort: z.number().optional(),
@@ -144,6 +145,7 @@ export default function GeneralForm() {
const form = useForm<GeneralFormValues>({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
enabled: resource.enabled,
name: resource.name, name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined, subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined, proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
@@ -209,6 +211,7 @@ export default function GeneralForm() {
.post<AxiosResponse<UpdateResourceResponse>>( .post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`, `resource/${resource?.resourceId}`,
{ {
enabled: data.enabled,
name: data.name, name: data.name,
subdomain: data.http ? data.subdomain : undefined, subdomain: data.http ? data.subdomain : undefined,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
@@ -236,6 +239,7 @@ export default function GeneralForm() {
const resource = res.data.data; const resource = res.data.data;
updateResource({ updateResource({
enabled: data.enabled,
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
@@ -282,54 +286,9 @@ export default function GeneralForm() {
setTransferLoading(false); setTransferLoading(false);
} }
async function toggleResourceEnabled(val: boolean) {
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource.resourceId}`,
{
enabled: val
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorToggle"),
description: formatAxiosError(
e,
t("resourceErrorToggleDescription")
)
});
});
updateResource({
enabled: val
});
}
return ( return (
!loadingPage && ( !loadingPage && (
<SettingsContainer> <SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceVisibilityTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceVisibilityTitleDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="enable-resource"
label={t("resourceEnable")}
defaultChecked={resource.enabled}
onCheckedChange={async (val) => {
await toggleResourceEnabled(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -348,6 +307,33 @@ export default function GeneralForm() {
className="grid grid-cols-1 md:grid-cols-2 gap-4" className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form" id="general-settings-form"
> >
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="col-span-2">
<div className="flex items-center space-x-2">
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={resource.enabled}
onCheckedChange={(val) => form.setValue("enabled", val)}
/>
</FormControl>
<div className="space-y-1">
<FormLabel className="text-base">
{t("resourceEnable")}
</FormLabel>
<FormDescription>
{t("resourceVisibilityTitleDescription")}
</FormDescription>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -612,7 +598,7 @@ export default function GeneralForm() {
disabled={saveLoading} disabled={saveLoading}
form="general-settings-form" form="general-settings-form"
> >
{t("saveGeneralSettings")} {t("saveSettings")}
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>

View File

@@ -233,35 +233,6 @@ export default function ResourceRules(props: {
); );
} }
async function saveApplyRules(val: boolean) {
const res = await api
.post(`/resource/${params.resourceId}`, {
applyRules: val
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorUpdate'),
description: formatAxiosError(
err,
t('rulesErrorUpdateDescription')
)
});
});
if (res && res.status === 200) {
setRulesEnabled(val);
updateResource({ applyRules: val });
toast({
title: t('rulesUpdated'),
description: t('rulesUpdatedDescription')
});
router.refresh();
}
}
function getValueHelpText(type: string) { function getValueHelpText(type: string) {
switch (type) { switch (type) {
case "CIDR": case "CIDR":
@@ -273,9 +244,33 @@ export default function ResourceRules(props: {
} }
} }
async function saveRules() { async function saveAllSettings() {
try { try {
setLoading(true); setLoading(true);
// Save rules enabled state
const res = await api
.post(`/resource/${params.resourceId}`, {
applyRules: rulesEnabled
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: t('rulesErrorUpdate'),
description: formatAxiosError(
err,
t('rulesErrorUpdateDescription')
)
});
throw err;
});
if (res && res.status === 200) {
updateResource({ applyRules: rulesEnabled });
}
// Save rules
for (let rule of rules) { for (let rule of rules) {
const data = { const data = {
action: rule.action, action: rule.action,
@@ -585,25 +580,6 @@ export default function ResourceRules(props: {
{/* </AlertDescription> */} {/* </AlertDescription> */}
{/* </Alert> */} {/* </Alert> */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t('rulesEnable')}</SettingsSectionTitle>
<SettingsSectionDescription>
{t('rulesEnableDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="rules-toggle"
label={t('rulesEnable')}
defaultChecked={rulesEnabled}
onCheckedChange={async (val) => {
await saveApplyRules(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -614,167 +590,186 @@ export default function ResourceRules(props: {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<Form {...addRuleForm}> <div className="space-y-6">
<form <div className="flex items-center space-x-2">
onSubmit={addRuleForm.handleSubmit(addRule)} <SwitchInput
className="space-y-4" id="rules-toggle"
> defaultChecked={rulesEnabled}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end"> onCheckedChange={(val) => setRulesEnabled(val)}
<FormField />
control={addRuleForm.control} <div className="space-y-1">
name="action" <label className="text-base font-medium">
render={({ field }) => ( {t('rulesEnable')}
<FormItem> </label>
<FormLabel>{t('rulesAction')}</FormLabel> <p className="text-sm text-muted-foreground">
<FormControl> {t('rulesEnableDescription')}
<Select </p>
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">
{RuleAction.ACCEPT}
</SelectItem>
<SelectItem value="DROP">
{RuleAction.DROP}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem className="gap-1">
<InfoPopup
text={t('value')}
info={
getValueHelpText(
addRuleForm.watch(
"match"
)
) || ""
}
/>
<FormControl>
<Input {...field}/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="secondary"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
</Button>
</div> </div>
</form> </div>
</Form>
<Table> <Form {...addRuleForm}>
<TableHeader> <form
{table.getHeaderGroups().map((headerGroup) => ( onSubmit={addRuleForm.handleSubmit(addRule)}
<TableRow key={headerGroup.id}> className="space-y-4"
{headerGroup.headers.map((header) => ( >
<TableHead key={header.id}> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
{header.isPlaceholder <FormField
? null control={addRuleForm.control}
: flexRender( name="action"
header.column.columnDef render={({ field }) => (
.header, <FormItem>
header.getContext() <FormLabel>{t('rulesAction')}</FormLabel>
)} <FormControl>
</TableHead> <Select
))} value={field.value}
</TableRow> onValueChange={
))} field.onChange
</TableHeader> }
<TableBody> >
{table.getRowModel().rows?.length ? ( <SelectTrigger className="w-full">
table.getRowModel().rows.map((row) => ( <SelectValue />
<TableRow key={row.id}> </SelectTrigger>
{row.getVisibleCells().map((cell) => ( <SelectContent>
<TableCell key={cell.id}> <SelectItem value="ACCEPT">
{flexRender( {RuleAction.ACCEPT}
cell.column.columnDef.cell, </SelectItem>
cell.getContext() <SelectItem value="DROP">
)} {RuleAction.DROP}
</TableCell> </SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addRuleForm.control}
name="value"
render={({ field }) => (
<FormItem className="gap-1">
<InfoPopup
text={t('value')}
info={
getValueHelpText(
addRuleForm.watch(
"match"
)
) || ""
}
/>
<FormControl>
<Input {...field}/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="secondary"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
</Button>
</div>
</form>
</Form>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))} ))}
</TableRow> </TableRow>
)) ))}
) : ( </TableHeader>
<TableRow> <TableBody>
<TableCell {table.getRowModel().rows?.length ? (
colSpan={columns.length} table.getRowModel().rows.map((row) => (
className="h-24 text-center" <TableRow key={row.id}>
> {row.getVisibleCells().map((cell) => (
{t('rulesNoOne')} <TableCell key={cell.id}>
</TableCell> {flexRender(
</TableRow> cell.column.columnDef.cell,
)} cell.getContext()
</TableBody> )}
{/* <TableCaption> */} </TableCell>
{/* {t('rulesOrder')} */} ))}
{/* </TableCaption> */} </TableRow>
</Table> ))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('rulesNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('rulesOrder')} */}
{/* </TableCaption> */}
</Table>
</div>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveRules}
loading={loading}
disabled={loading}
>
{t('rulesSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection> </SettingsSection>
<div className="flex justify-end">
<Button
onClick={saveAllSettings}
loading={loading}
disabled={loading}
>
{t('saveAllSettings')}
</Button>
</div>
</SettingsContainer> </SettingsContainer>
); );
} }

View File

@@ -24,8 +24,7 @@ import {
SettingsSectionTitle, SettingsSectionTitle,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionForm, SettingsSectionForm
SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
@@ -177,18 +176,18 @@ export default function GeneralPage() {
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
{t("saveGeneralSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection> </SettingsSection>
<div className="flex justify-end mt-6">
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
Save All Settings
</Button>
</div>
</SettingsContainer> </SettingsContainer>
); );
} }

View File

@@ -4,7 +4,7 @@ import { Label } from "./ui/label";
interface SwitchComponentProps { interface SwitchComponentProps {
id: string; id: string;
label: string; label?: string;
description?: string; description?: string;
defaultChecked?: boolean; defaultChecked?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -28,7 +28,7 @@ export function SwitchInput({
onCheckedChange={onCheckedChange} onCheckedChange={onCheckedChange}
disabled={disabled} disabled={disabled}
/> />
<Label htmlFor={id}>{label}</Label> {label && <Label htmlFor={id}>{label}</Label>}
</div> </div>
{description && ( {description && (
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">

View File

@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
@@ -43,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className

View File

@@ -31,7 +31,7 @@ const toastVariants = cva(
variant: { variant: {
default: "border bg-card text-foreground", default: "border bg-card text-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground" "destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground"
} }
}, },
defaultVariants: { defaultVariants: {