mirror of
https://github.com/supabase/supabase.git
synced 2026-06-03 19:32:27 +08:00
## What kind of change does this PR introduce? Feature. Resolves FE-3470. ## What is the current behavior? Organization surfaces have a `G then ,` shortcut to enter org settings, but once inside there is no keyboard navigation, sidebar tooltips, or action shortcuts for the various org pages. | Area | Current behaviour | | --- | --- | | Org Settings sidebar | Routes are click-only once users are inside Settings. | | OAuth Apps | Publish / confirm actions have no keyboard shortcuts. | | Private Apps | Create app has no keyboard shortcut. | | Team | Invite / send actions have no keyboard shortcuts. | | Integrations | Add project connection has no keyboard shortcut. | | Org Projects | New project and search have no keyboard shortcuts. | | Audit Logs | Refresh has no keyboard shortcut. | ## What is the new behavior? Mirrors the Project Settings shortcut pattern (#46352) across all Organization surfaces. | Area | New shortcut coverage | | --- | --- | | Org Settings sidebar | `S then G/C/S/A/P/W/L/D` for General, Security, SSO, OAuth apps, Private apps, Webhooks, Audit logs, Legal documents. Shortcut badge appears on hover in the sidebar. | | Org Settings entry | `G then ,` (remapped from `G then O`) to match the Project Settings chord. | | OAuth Apps | `Shift+N` opens Publish app panel; `Mod+Enter` confirms the open panel. | | Private Apps | `Shift+N` opens Create app sheet (works in both empty-state and list-state). | | Team | `Shift+N` opens Invite members dialog; `Mod+Enter` sends the invitation(s). | | Integrations | `Shift+N` triggers Add project connection when permitted. | | Org Projects | `Shift+N` navigates to new project; `Shift+F` focuses the search input. | | Audit Logs | `Shift+R` refreshes the log list. | ### Implementation notes - Threads `shortcutId` through the `WithSidebar` pipeline (`SidebarLink` → `SubMenuSection` → `ProductMenuGroup`) so tooltip display is automatic — no new rendering logic. - Layout-scoped chords mount only while `OrganizationSettingsLayout` is active, so `S then G` in org settings does not conflict with `S then G` in project settings. - Cheatsheet reference groups promoted to typed constants with readable labels (was: bare strings like `'org-oauth-apps'`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * System-wide keyboard shortcuts for org areas: project search & new project, private app creation, OAuth app publish/confirm, add GitHub integration, invite members (open/submit), and refresh audit logs. * Sidebar and product menu now show assigned shortcuts for faster navigation; org settings navigation shortcut remapped. * **Tests** * Added coverage for org shortcut registry behavior, sequences, and ordering. * **Chores** * New shortcut reference groups and ordering for improved discoverability. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46356?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 --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Ali Waseem <waseema393@gmail.com>
479 lines
18 KiB
TypeScript
479 lines
18 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import type { OAuthScope } from '@supabase/shared-types/out/constants'
|
|
import { useParams } from 'common'
|
|
import { Edit, Upload } from 'lucide-react'
|
|
import { ChangeEvent, useEffect, useRef, useState } from 'react'
|
|
import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
Badge,
|
|
Button,
|
|
cn,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
Input,
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupInput,
|
|
SidePanel,
|
|
} from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import * as z from 'zod'
|
|
|
|
import { AuthorizeRequesterDetails } from '../AuthorizeRequesterDetails'
|
|
import { OAuthSecrets } from '../OAuthSecrets/OAuthSecrets'
|
|
import { ScopesPanel } from './Scopes'
|
|
import { DocsButton } from '@/components/ui/DocsButton'
|
|
import { Shortcut } from '@/components/ui/Shortcut'
|
|
import {
|
|
OAuthAppCreateResponse,
|
|
useOAuthAppCreateMutation,
|
|
} from '@/data/oauth/oauth-app-create-mutation'
|
|
import { useOAuthAppUpdateMutation } from '@/data/oauth/oauth-app-update-mutation'
|
|
import type { OAuthApp } from '@/data/oauth/oauth-apps-query'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
import { isValidHttpUrl, uuidv4 } from '@/lib/helpers'
|
|
import { uploadAttachment } from '@/lib/upload'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
|
|
export interface PublishAppSidePanelProps {
|
|
visible: boolean
|
|
selectedApp?: OAuthApp
|
|
onClose: () => void
|
|
onCreateSuccess: (app: OAuthAppCreateResponse) => void
|
|
}
|
|
|
|
const formSchema = z.object({
|
|
name: z.string().min(1, 'Please provide a name for your application'),
|
|
website: z
|
|
.string()
|
|
.min(1, 'Please provide a URL for your site')
|
|
.url('Please provide a URL for your site')
|
|
.refine((value) => isValidHttpUrl(value), 'Please provide a valid URL for your site'),
|
|
redirect_uris: z
|
|
.array(
|
|
z.object({
|
|
id: z.string(),
|
|
value: z.string().min(1, 'Please provide a URL').url('Please provide a URL'),
|
|
}),
|
|
{ required_error: 'Please provide at least one callback URL' }
|
|
)
|
|
.min(1, 'Please provide at least one callback URL'),
|
|
})
|
|
|
|
const getFormDefaultValues = (selectedApp: OAuthApp | undefined) => {
|
|
if (selectedApp) {
|
|
return {
|
|
name: selectedApp.name,
|
|
website: selectedApp.website,
|
|
redirect_uris:
|
|
selectedApp.redirect_uris?.map((url) => {
|
|
return { id: uuidv4(), value: url }
|
|
}) ?? [],
|
|
}
|
|
}
|
|
|
|
return { name: '', website: '', redirect_uris: [{ id: uuidv4(), value: '' }] }
|
|
}
|
|
|
|
type FormSchema = z.infer<typeof formSchema>
|
|
|
|
export const PublishAppSidePanel = ({
|
|
visible,
|
|
selectedApp,
|
|
onClose,
|
|
onCreateSuccess,
|
|
}: PublishAppSidePanelProps) => {
|
|
const { slug } = useParams()
|
|
const uploadButtonRef = useRef<HTMLInputElement | null>(null)
|
|
|
|
const { mutateAsync: createOAuthApp } = useOAuthAppCreateMutation({
|
|
onSuccess: (res, variables) => {
|
|
toast.success(`Successfully created OAuth app "${variables.name}"!`)
|
|
onClose()
|
|
onCreateSuccess(res)
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Failed to create OAuth application: ${error.message}`)
|
|
},
|
|
})
|
|
const { mutateAsync: updateOAuthApp } = useOAuthAppUpdateMutation({
|
|
onSuccess: (_, variables) => {
|
|
toast.success(`Successfully updated OAuth app "${variables.name}"!`)
|
|
onClose()
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Failed to update OAuth application: ${error.message}`)
|
|
},
|
|
})
|
|
|
|
const [showPreview, setShowPreview] = useState(false)
|
|
const [iconFile, setIconFile] = useState<File>()
|
|
const [iconUrl, setIconUrl] = useState<string>()
|
|
const [scopes, setScopes] = useState<OAuthScope[]>([])
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
setIconFile(undefined)
|
|
|
|
if (selectedApp !== undefined) {
|
|
setScopes((selectedApp?.scopes ?? []) as OAuthScope[])
|
|
setIconUrl(selectedApp.icon === null ? undefined : selectedApp.icon)
|
|
} else {
|
|
setScopes([])
|
|
setIconUrl(undefined)
|
|
}
|
|
}
|
|
}, [visible, selectedApp])
|
|
|
|
const onFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
event.persist()
|
|
const [file] = event.target.files || (event as any).dataTransfer.items
|
|
setIconFile(file)
|
|
setIconUrl(URL.createObjectURL(file))
|
|
event.target.value = ''
|
|
}
|
|
|
|
const onSubmit: SubmitHandler<FormSchema> = async (values) => {
|
|
if (!slug) return console.error('Slug is required')
|
|
|
|
const { name, website, redirect_uris } = values
|
|
const uploadedIconUrl =
|
|
iconFile !== undefined
|
|
? await uploadAttachment('oauth-app-icons', `${slug}/${uuidv4()}.png`, iconFile)
|
|
: iconUrl
|
|
|
|
if (iconFile !== undefined && uploadedIconUrl === undefined) {
|
|
toast.error('Failed to upload OAuth application icon')
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (selectedApp === undefined) {
|
|
// Create application
|
|
await createOAuthApp({
|
|
slug,
|
|
name,
|
|
website,
|
|
redirect_uris: redirect_uris.map((uris) => uris.value),
|
|
scopes,
|
|
icon: uploadedIconUrl,
|
|
})
|
|
} else {
|
|
// Update application
|
|
await updateOAuthApp({
|
|
id: selectedApp.id,
|
|
slug,
|
|
name,
|
|
website,
|
|
redirect_uris: redirect_uris.map((uris) => uris.value),
|
|
scopes,
|
|
icon: uploadedIconUrl,
|
|
})
|
|
}
|
|
} catch {
|
|
// Error side effects are handled in the mutation hook options
|
|
}
|
|
}
|
|
|
|
const form = useForm<FormSchema>({
|
|
defaultValues: getFormDefaultValues(selectedApp),
|
|
resolver: zodResolver(formSchema),
|
|
})
|
|
const { reset } = form
|
|
const { errors, isSubmitting } = form.formState
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
const defaultValues = getFormDefaultValues(selectedApp)
|
|
reset(defaultValues)
|
|
}
|
|
}, [visible, selectedApp, reset])
|
|
|
|
const name = useWatch({ name: 'name', control: form.control })
|
|
const website = useWatch({ name: 'website', control: form.control })
|
|
|
|
const {
|
|
fields: callbackUrlsFields,
|
|
append: appendCallbackUrl,
|
|
remove: removeCallbackUrl,
|
|
} = useFieldArray({
|
|
name: 'redirect_uris',
|
|
control: form.control,
|
|
})
|
|
|
|
return (
|
|
<SidePanel
|
|
hideFooter
|
|
size="large"
|
|
visible={visible}
|
|
header={
|
|
selectedApp !== undefined ? 'Update OAuth application' : 'Publish a new OAuth application'
|
|
}
|
|
onCancel={() => onClose()}
|
|
>
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
<div className="h-full flex flex-col">
|
|
<div className="grow">
|
|
<SidePanel.Content>
|
|
<div className="py-4 flex items-start justify-between gap-10">
|
|
<div className="space-y-4 w-full">
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="vertical"
|
|
label="Application name"
|
|
description={selectedApp?.id && `ID: ${selectedApp.id}`}
|
|
>
|
|
<FormControl className="col-span-6">
|
|
<Input {...field} />
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="website"
|
|
render={({ field }) => (
|
|
<FormItemLayout layout="vertical" label="Website URL">
|
|
<FormControl className="col-span-6">
|
|
<Input {...field} placeholder="https://my-website.com" />
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
{iconUrl !== undefined ? (
|
|
<div
|
|
className={cn(
|
|
'shadow-sm transition group relative',
|
|
'bg-center bg-cover bg-no-repeat',
|
|
'mt-4 mr-4 space-y-2 rounded-full h-[120px] w-[120px] flex flex-col items-center justify-center'
|
|
)}
|
|
style={{
|
|
backgroundImage: iconUrl ? `url("${iconUrl}")` : 'none',
|
|
}}
|
|
>
|
|
<div className="absolute bottom-1 right-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button type="default" className="px-1">
|
|
<Edit />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" side="bottom">
|
|
<DropdownMenuItem
|
|
key="upload"
|
|
onClick={() => {
|
|
if (uploadButtonRef.current)
|
|
(uploadButtonRef.current as any).click()
|
|
}}
|
|
>
|
|
<p>Upload image</p>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
key="remove"
|
|
onClick={() => {
|
|
setIconFile(undefined)
|
|
setIconUrl(undefined)
|
|
}}
|
|
>
|
|
<p>Remove image</p>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
'border border-strong transition opacity-75 hover:opacity-100',
|
|
'mt-4 mr-4 space-y-2 rounded-full h-[120px] w-[120px] flex flex-col items-center justify-center cursor-pointer'
|
|
)}
|
|
onClick={() => {
|
|
if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
|
|
}}
|
|
>
|
|
<Upload size={18} strokeWidth={1.5} className="text-foreground" />
|
|
<p className="text-xs text-foreground-light">Upload logo</p>
|
|
</div>
|
|
)}
|
|
<input
|
|
multiple
|
|
type="file"
|
|
ref={uploadButtonRef}
|
|
className="hidden"
|
|
accept="image/png, image/jpeg"
|
|
onChange={onFileUpload}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SidePanel.Content>
|
|
|
|
<SidePanel.Separator />
|
|
|
|
<SidePanel.Content className="py-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-foreground text-sm">Authorization callback URLs</p>
|
|
<p className="text-sm text-foreground-light">
|
|
All URLs must use HTTPS, except for localhost
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="default"
|
|
onClick={() => appendCallbackUrl({ id: uuidv4(), value: '' })}
|
|
>
|
|
Add URL
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2 pb-2">
|
|
{callbackUrlsFields.map((url, index) => (
|
|
<FormField
|
|
key={url.id}
|
|
control={form.control}
|
|
name={`redirect_uris.${index}.value`}
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="vertical"
|
|
label={<span className="sr-only">Callback URL</span>}
|
|
>
|
|
<FormControl>
|
|
<InputGroup>
|
|
<InputGroupInput
|
|
{...field}
|
|
placeholder="e.g https://my-website.com"
|
|
/>
|
|
{callbackUrlsFields.length > 1 ? (
|
|
<InputGroupAddon align="inline-end">
|
|
<InputGroupButton
|
|
type="default"
|
|
onClick={() => removeCallbackUrl(index)}
|
|
>
|
|
Remove
|
|
</InputGroupButton>
|
|
</InputGroupAddon>
|
|
) : null}
|
|
</InputGroup>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
))}
|
|
{errors.redirect_uris?.root != null ? (
|
|
<p className="text-red-900 text-sm">{errors.redirect_uris?.root.message}</p>
|
|
) : null}
|
|
</div>
|
|
</SidePanel.Content>
|
|
|
|
{selectedApp !== undefined && (
|
|
<>
|
|
<SidePanel.Separator />
|
|
<SidePanel.Content className="py-4">
|
|
<OAuthSecrets selectedApp={selectedApp} />
|
|
</SidePanel.Content>
|
|
</>
|
|
)}
|
|
|
|
<SidePanel.Separator />
|
|
<div className="p-6 ">
|
|
<div className="flex items-start justify-between space-x-4 pb-4">
|
|
<div className="flex flex-col">
|
|
<span className="text-sm text-foreground">Application permissions</span>
|
|
<span className="text-sm text-foreground-light">
|
|
The application permissions are organized in scopes and will be presented to
|
|
the user when adding an app to their organization and all of its projects.
|
|
</span>
|
|
</div>
|
|
<DocsButton href={`${DOCS_URL}/guides/platform/oauth-apps/oauth-scopes`} />
|
|
</div>
|
|
|
|
<ScopesPanel scopes={scopes} setScopes={setScopes} />
|
|
</div>
|
|
</div>
|
|
|
|
<SidePanel.Separator />
|
|
|
|
<SidePanel.Content>
|
|
<div className="pt-2 pb-3 flex items-center justify-between">
|
|
<Button
|
|
type="default"
|
|
onClick={() => setShowPreview(true)}
|
|
disabled={name.length === 0 || website.length === 0}
|
|
>
|
|
Preview consent for users
|
|
</Button>
|
|
<div className="flex items-center space-x-2">
|
|
<Button type="default" disabled={isSubmitting} onClick={() => onClose()}>
|
|
Cancel
|
|
</Button>
|
|
<Shortcut
|
|
id={SHORTCUT_IDS.ORG_OAUTH_APPS_SUBMIT}
|
|
onTrigger={() => form.handleSubmit(onSubmit)()}
|
|
options={{ enabled: visible && !isSubmitting }}
|
|
side="top"
|
|
>
|
|
<Button htmlType="submit" loading={isSubmitting} disabled={isSubmitting}>
|
|
Confirm
|
|
</Button>
|
|
</Shortcut>
|
|
</div>
|
|
</div>
|
|
</SidePanel.Content>
|
|
</div>
|
|
|
|
<AlertDialog open={showPreview} onOpenChange={(open) => setShowPreview(open)}>
|
|
<AlertDialogContent size="large">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
<div className="flex items-center gap-x-2 justify-between">
|
|
<p className="truncate">Authorize API access for {name}</p>
|
|
<Badge variant="success">Preview</Badge>
|
|
</div>
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
<AuthorizeRequesterDetails
|
|
icon={iconUrl || null}
|
|
name={name}
|
|
domain={website}
|
|
scopes={scopes}
|
|
/>
|
|
<div className="pt-4 space-y-2">
|
|
<p className="prose text-sm">Select an organization to grant API access to</p>
|
|
<div className="border border-control text-foreground-light rounded-sm px-4 py-2 text-sm bg-surface-200">
|
|
Organizations that you have access to will be listed here
|
|
</div>
|
|
</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter className="items-baseline sm:justify-between">
|
|
<p className="prose text-xs">
|
|
This is what your users will see when authorizing with your app
|
|
</p>
|
|
<AlertDialogCancel>Close</AlertDialogCancel>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</form>
|
|
</Form>
|
|
</SidePanel>
|
|
)
|
|
}
|