mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
## What kind of change does this PR introduce? Mechanical cleanup on top of the Button default-variant change (#50160). ## What is the current behavior? Many callsites still pass `variant="default"` even though that is now the component default. ## What is the new behavior? Removes redundant static `variant="default"` from legacy `Button` and `ButtonTooltip` callsites. Keeps explicit defaults where they document the API: - `button-default.tsx` and `button-sizes.tsx` demos - `DocsButton`, which pins neutral styling at the wrapper boundary ## To test Studio: - [Auth → Rate Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits): dirty the form so Cancel appears; Cancel stays neutral, Save stays green - [Project Settings → API Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys): `DocsButton` in the header actions stays neutral Design system: - [Design system → Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button): `button-default` / `button-sizes` still show explicit default styling; Primary (green) is restricted to the Primary section (and `asChild`) WWW: - [www → Brand assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets): Download logo kit / Download button kit stay neutral
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useParams } from 'common'
|
|
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
|
import { useEffect, useMemo } from 'react'
|
|
import { useForm, useWatch } from 'react-hook-form'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
ScrollArea,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from 'ui'
|
|
import { Admonition } from 'ui-patterns/Admonition'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import { z } from 'zod'
|
|
|
|
import type {
|
|
JitExpiryMode,
|
|
JitMemberOption,
|
|
JitUserRuleDraft,
|
|
SheetMode,
|
|
} from './JitDbAccess.types'
|
|
import {
|
|
createDraft,
|
|
draftFromRule,
|
|
getAssignableJitRoleOptions,
|
|
getInvalidIpRangeRows,
|
|
mapJitMembersToUserRules,
|
|
serializeDraftRolesForGrantMutation,
|
|
} from './JitDbAccess.utils'
|
|
import { JitDbAccessRoleGrantFields } from './JitDbAccessRoleGrantFields'
|
|
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
|
import { InlineLink } from '@/components/ui/InlineLink'
|
|
import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
|
|
import { useJitDbAccessGrantMutation } from '@/data/jit-db-access/jit-db-access-grant-mutation'
|
|
import { useJitDbAccessMembersQuery } from '@/data/jit-db-access/jit-db-access-members-query'
|
|
import { useProjectMembersQuery } from '@/data/projects/project-members-query'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
|
|
const grantSchema = z.object({
|
|
roleId: z.string(),
|
|
enabled: z.boolean(),
|
|
branchesOnly: z.boolean(),
|
|
expiryMode: z.custom<JitExpiryMode>(),
|
|
hasExpiry: z.boolean(),
|
|
expiry: z.string(),
|
|
ipRanges: z.array(z.object({ value: z.string() })),
|
|
})
|
|
|
|
function createJitRuleSchema(mode: SheetMode, membersWithRules: Set<string>) {
|
|
return z
|
|
.object({
|
|
memberId: z.string().min(1, 'Select a member for this temporary access rule.'),
|
|
grants: z.array(grantSchema),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (mode === 'add' && membersWithRules.has(data.memberId)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['memberId'],
|
|
message:
|
|
'This member already has a temporary access rule. Edit their existing rule from the list.',
|
|
})
|
|
}
|
|
|
|
const enabledGrantCount = data.grants.filter((g) => g.enabled).length
|
|
if (enabledGrantCount === 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['grants'],
|
|
message: 'Select at least one role.',
|
|
})
|
|
return
|
|
}
|
|
|
|
data.grants.forEach((grant, grantIndex) => {
|
|
if (!grant.enabled) return
|
|
|
|
const invalidCidrs = new Set(getInvalidIpRangeRows(grant.ipRanges))
|
|
|
|
grant.ipRanges.forEach((ipRange, ipRangeIndex) => {
|
|
const value = ipRange.value.trim()
|
|
if (value.length === 0 || !invalidCidrs.has(value)) return
|
|
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['grants', grantIndex, 'ipRanges', ipRangeIndex, 'value'],
|
|
message: 'Please enter a valid CIDR range',
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
interface JitDbAccessRuleSheetProps {
|
|
memberOptions: JitMemberOption[]
|
|
membersWithRules: Set<string>
|
|
availableMembersForAddCount: number
|
|
}
|
|
|
|
/**
|
|
* [Joshen] Form schema can be further refactored to simplify
|
|
* It's weird that we're rendering the role options based on the form, when it's just based on
|
|
* the available database roles - should decouple
|
|
*/
|
|
|
|
export function JitDbAccessRuleSheet({
|
|
memberOptions,
|
|
membersWithRules,
|
|
availableMembersForAddCount,
|
|
}: JitDbAccessRuleSheetProps) {
|
|
const { ref: projectRef } = useParams()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
|
|
const [isNewRule, setIsNewRule] = useQueryState('jit_new', parseAsBoolean.withDefault(false))
|
|
const [ruleIdToEdit, setRuleIdToEdit] = useQueryState('jit_edit', parseAsString)
|
|
|
|
const { data: databaseRoles, isSuccess: isSuccessDatabaseRoles } = useDatabaseRolesQuery({
|
|
projectRef,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles])
|
|
const roleIds = useMemo(() => roleOptions.map((role) => role.id), [roleOptions])
|
|
|
|
const { data: jitMembers, isSuccess: isSuccessJitMembers } = useJitDbAccessMembersQuery({
|
|
projectRef,
|
|
})
|
|
const { data: projectMembers, isSuccess: isSuccessProjectMembers } = useProjectMembersQuery({
|
|
projectRef,
|
|
})
|
|
const users = useMemo(
|
|
() => mapJitMembersToUserRules(jitMembers, projectMembers, roleOptions),
|
|
[jitMembers, projectMembers, roleOptions]
|
|
)
|
|
const user = users.find((x) => x.id === ruleIdToEdit)
|
|
const mode: SheetMode = !!user ? 'edit' : 'add'
|
|
|
|
const isDataReady = isSuccessDatabaseRoles && isSuccessJitMembers && isSuccessProjectMembers
|
|
const open = isNewRule || (!!ruleIdToEdit && !!user)
|
|
|
|
const defaultValues = !isNewRule && !!user ? draftFromRule(user, roleIds) : createDraft(roleIds)
|
|
const FormSchema = useMemo(
|
|
() => createJitRuleSchema(mode, membersWithRules),
|
|
[mode, membersWithRules]
|
|
)
|
|
const form = useForm<JitUserRuleDraft>({
|
|
defaultValues,
|
|
resolver: zodResolver(FormSchema),
|
|
})
|
|
const grants = useWatch({ control: form.control, name: 'grants' })
|
|
|
|
const onCloseSheet = () => {
|
|
setIsNewRule(false)
|
|
setRuleIdToEdit(null)
|
|
}
|
|
|
|
const {
|
|
confirmOnClose,
|
|
handleOpenChange,
|
|
modalProps: closeConfirmationModalProps,
|
|
} = useConfirmOnClose({
|
|
checkIsDirty: () => form.formState.isDirty,
|
|
onClose: onCloseSheet,
|
|
})
|
|
|
|
const { mutate: grantUserAccess, isPending: isSubmitting } = useJitDbAccessGrantMutation({
|
|
onSuccess: () => {
|
|
toast.success(
|
|
mode === 'edit' ? 'Successfully updated user access' : 'Successfully granted user access'
|
|
)
|
|
onCloseSheet()
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Failed to ${mode === 'edit' ? 'update' : 'grant'} user access: ${error.message}`)
|
|
},
|
|
})
|
|
|
|
const updateGrant = (
|
|
roleId: string,
|
|
updater: (grant: JitUserRuleDraft['grants'][number]) => JitUserRuleDraft['grants'][number]
|
|
) => {
|
|
const nextGrants = grants.map((grant) => (grant.roleId === roleId ? updater(grant) : grant))
|
|
form.setValue('grants', nextGrants, { shouldDirty: true })
|
|
}
|
|
|
|
const handleSaveRule = (data: z.infer<typeof FormSchema>) => {
|
|
if (!projectRef) return console.error('Project ref is required')
|
|
|
|
const roles = serializeDraftRolesForGrantMutation(data)
|
|
if (roles.length === 0) return
|
|
|
|
grantUserAccess({ projectRef, userId: data.memberId, roles })
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!!ruleIdToEdit && isDataReady && !user) {
|
|
toast('Access rule cannot be found')
|
|
setRuleIdToEdit(null)
|
|
}
|
|
}, [isDataReady, ruleIdToEdit, setRuleIdToEdit, user])
|
|
|
|
useEffect(() => {
|
|
if (open && isDataReady) form.reset(defaultValues)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, isDataReady])
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
<SheetContent
|
|
showClose={false}
|
|
size="default"
|
|
className="flex h-full w-full max-w-full flex-col gap-0 sm:w-[560px]! sm:max-w-[560px]"
|
|
>
|
|
<SheetHeader>
|
|
<SheetTitle>
|
|
{mode === 'edit' ? 'Edit temporary access rule' : 'New temporary access rule'}
|
|
</SheetTitle>
|
|
<SheetDescription className="sr-only">
|
|
Configure which database roles a user can request with temporary access.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<Form {...form}>
|
|
<ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
|
|
<div className="space-y-8 px-5 py-6 sm:px-6">
|
|
<FormField
|
|
control={form.control}
|
|
name="memberId"
|
|
render={({ field }) => (
|
|
<FormItemLayout layout="vertical" label="Member">
|
|
<FormControl>
|
|
<Select
|
|
value={field.value}
|
|
disabled={
|
|
mode === 'edit' || (mode === 'add' && availableMembersForAddCount === 0)
|
|
}
|
|
onValueChange={field.onChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a member" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{memberOptions.map((member) => (
|
|
<SelectItem key={member.id} value={member.id}>
|
|
{member.name ? (
|
|
<>
|
|
{member.name}{' '}
|
|
<span className="text-foreground-lighter">
|
|
({member.email})
|
|
</span>
|
|
</>
|
|
) : (
|
|
member.email
|
|
)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
{mode === 'add' && availableMembersForAddCount === 0 && (
|
|
<p className="mt-2 text-foreground-lighter">
|
|
All project members already have temporary access rules. Edit an existing
|
|
rule from the table above.
|
|
</p>
|
|
)}
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="grants"
|
|
render={() => (
|
|
<FormItemLayout
|
|
layout="vertical"
|
|
label="Roles and settings"
|
|
description={
|
|
<>
|
|
Use{' '}
|
|
<InlineLink
|
|
href={`${DOCS_URL}/guides/database/postgres/roles`}
|
|
className="decoration-foreground-muted"
|
|
>
|
|
custom Postgres roles
|
|
</InlineLink>{' '}
|
|
with narrow permissions to reduce the impact of direct database access.
|
|
</>
|
|
}
|
|
>
|
|
{grants.length === 0 ? (
|
|
<Admonition
|
|
type="note"
|
|
description="No assignable roles found."
|
|
className="bg-background"
|
|
/>
|
|
) : (
|
|
<div className="overflow-hidden rounded-md border">
|
|
{grants.map((grant, index) => (
|
|
<div key={grant.roleId} className={index > 0 ? 'border-t' : ''}>
|
|
<JitDbAccessRoleGrantFields
|
|
control={form.control}
|
|
grantIndex={index}
|
|
role={{ id: grant.roleId, label: grant.roleId }}
|
|
grant={grant}
|
|
onChange={(next) => updateGrant(grant.roleId, () => next)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</div>
|
|
</ScrollArea>
|
|
</Form>
|
|
|
|
<SheetFooter className="mt-auto w-full border-t py-4">
|
|
<Button onClick={confirmOnClose} disabled={isSubmitting}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={form.handleSubmit(handleSaveRule)}
|
|
loading={isSubmitting}
|
|
>
|
|
{mode === 'edit' ? 'Save rule' : 'Create rule'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
<DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
|
|
</>
|
|
)
|
|
}
|