mirror of
https://github.com/supabase/supabase.git
synced 2026-06-13 01:39:53 +08:00
## What kind of change does this PR introduce? Chore. Follow-up to DEPR-551, #45302, #45535, and #45618. ## What is the current behaviour? Some short Studio Admonitions still put their entire message in `title` or legacy `label`, so body-copy callouts render as headings. ## What is the new behaviour? Moves selected single-message Studio Admonitions to `description`, keeping the follow-up deliberately limited to Studio callsites. This PR does not touch Docs content, shared Alert styling, ui-patterns, design-system registry/docs, or Tailwind config. | Before | After | | --- | --- | | <img width="1818" height="388" alt="Image" src="https://github.com/user-attachments/assets/283a1853-348a-4d74-a408-013957350e5e" /> | <img width="1380" height="462" alt="Image" src="https://github.com/user-attachments/assets/e5761e8e-3697-423b-805b-45110205099a" /> | | <img width="1640" height="716" alt="CleanShot 2026-04-28 at 15 17 25@2x" src="https://github.com/user-attachments/assets/a5be4d5f-2bf7-4dc2-b396-56129fe64ec9" /> | <img width="1630" height="716" alt="CleanShot 2026-04-28 at 15 16 00@2x" src="https://github.com/user-attachments/assets/0d589252-aaf8-4efc-9d81-15ec4f99ec61" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Style** * Refined message displays and admonition styling across settings, database, dashboard, and admin interfaces for improved visual consistency and clarity. * **UI Updates** * Updated search input layouts and form element styling in publications tables and other admin pages. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46049?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
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 } 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'
|
|
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 = form.watch('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 type="default" onClick={confirmOnClose} disabled={isSubmitting}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
onClick={form.handleSubmit(handleSaveRule)}
|
|
loading={isSubmitting}
|
|
>
|
|
{mode === 'edit' ? 'Save rule' : 'Create rule'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
<DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
|
|
</>
|
|
)
|
|
}
|