Skip to content

Commit a1f2e50

Browse files
cstocktonChris Stocktonjoshenlim
authored
feat(profile): add self-serve editing of username and primary email (supabase#38042)
* feat(profile): add self-serve editing of username and primary email Users can now update `username` and choose a `primary_email` directly in the Dashboard profile editor. Changes: - Add `username` and `primary_email` fields to the profile form. - Introduce `Select_Shadcn_` dropdown listing the user's identities (via `useProfileIdentitiesQuery`) for selecting a primary email. - Default `primary_email` selection is derived from the current profile. - Extend form submission to patch `username` and `primary_email` to the profile API. - Wire zod schema and react-hook-form defaults for new fields. * chore: remove unused variable * fix: disable username & primary_email modification for SSO users * Small refactors * Remove hard code * Refactor to use FormItemLayout * chore: add descriptions for primary_email and username * chore: fix format and lint error * chore: fix lint error * Use identities to derive user dropdown profile image, and remove profile image from useProfile hook --------- Co-authored-by: Chris Stockton <[email protected]> Co-authored-by: Joshen Lim <[email protected]>
1 parent 687667c commit a1f2e50

File tree

7 files changed

+209
-75
lines changed

7 files changed

+209
-75
lines changed

apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ import {
3131
SSOChangeEmailAddress,
3232
} from './ChangeEmailAddress'
3333

34+
const getProviderName = (provider: string) =>
35+
provider === 'github'
36+
? 'GitHub'
37+
: provider.startsWith('sso')
38+
? 'SSO'
39+
: provider.replaceAll('_', ' ')
40+
3441
export const AccountIdentities = () => {
3542
const router = useRouter()
3643

@@ -54,9 +61,6 @@ export const AccountIdentities = () => {
5461

5562
const [_, message] = router.asPath.split('#message=')
5663

57-
const getProviderName = (provider: string) =>
58-
provider === 'github' ? 'GitHub' : provider.startsWith('sso') ? 'SSO' : provider
59-
6064
const onConfirmUnlinkIdentity = async () => {
6165
const identity = identities.find((i) => i.provider === selectedProviderUnlink)
6266
if (identity) unlinkIdentity(identity)
@@ -115,7 +119,7 @@ export const AccountIdentities = () => {
115119
</div>
116120
<p className="text-sm text-foreground-lighter">
117121
{!!username ? <span>{username}</span> : null}
118-
{(identity as any).email}
122+
{identity.email}
119123
</p>
120124
</div>
121125
</div>

apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx

Lines changed: 130 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,102 +2,187 @@ import { zodResolver } from '@hookform/resolvers/zod'
22
import { SubmitHandler, useForm } from 'react-hook-form'
33
import { toast } from 'sonner'
44
import {
5+
Button,
6+
Card,
7+
CardContent,
8+
CardFooter,
9+
CardHeader,
510
FormControl_Shadcn_,
611
FormField_Shadcn_,
7-
FormItem_Shadcn_,
8-
FormLabel_Shadcn_,
9-
FormMessage_Shadcn_,
1012
Form_Shadcn_,
1113
Input_Shadcn_,
14+
SelectContent_Shadcn_,
15+
SelectItem_Shadcn_,
16+
SelectTrigger_Shadcn_,
17+
SelectValue_Shadcn_,
18+
Select_Shadcn_,
1219
} from 'ui'
1320
import z from 'zod'
1421

15-
import { FormActions } from 'components/ui/Forms/FormActions'
16-
import Panel from 'components/ui/Panel'
22+
import { useProfileIdentitiesQuery } from 'data/profile/profile-identities-query'
1723
import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation'
1824
import { useProfile } from 'lib/profile'
25+
import { groupBy } from 'lodash'
1926
import type { FormSchema } from 'types'
27+
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
2028

2129
const FormSchema = z.object({
2230
first_name: z.string().optional(),
2331
last_name: z.string().optional(),
32+
username: z.string().optional(),
33+
primary_email: z.string().email().optional(),
2434
})
2535

2636
const formId = 'profile-information-form'
2737

2838
export const ProfileInformation = () => {
2939
const { profile } = useProfile()
3040

41+
const {
42+
data: identityData,
43+
isLoading: isIdentitiesLoading,
44+
isSuccess: isIdentitiesSuccess,
45+
} = useProfileIdentitiesQuery()
46+
const identities = (identityData?.identities ?? []).filter((x) => x.identity_data?.email !== null)
47+
const dedupedIdentityEmails = Object.keys(groupBy(identities, 'identity_data.email'))
48+
49+
const defaultValues = {
50+
first_name: profile?.first_name ?? '',
51+
last_name: profile?.last_name ?? '',
52+
username: profile?.username ?? '',
53+
primary_email: profile?.primary_email ?? '',
54+
}
55+
3156
const form = useForm({
3257
resolver: zodResolver(FormSchema),
33-
defaultValues: { first_name: profile?.first_name ?? '', last_name: profile?.last_name ?? '' },
58+
defaultValues,
59+
values: defaultValues,
3460
})
3561

3662
const { mutate: updateProfile, isLoading } = useProfileUpdateMutation({
3763
onSuccess: (data) => {
3864
toast.success('Successfully saved profile')
39-
form.reset({ first_name: data.first_name, last_name: data.last_name })
65+
const { first_name, last_name, username, primary_email } = data
66+
form.reset({ first_name, last_name, username, primary_email })
4067
},
4168
onError: (error) => toast.error(`Failed to update profile: ${error.message}`),
4269
})
4370

4471
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
45-
updateProfile({ firstName: data.first_name || '', lastName: data.last_name || '' })
72+
updateProfile({
73+
firstName: data.first_name || '',
74+
lastName: data.last_name || '',
75+
username: data.username || '',
76+
primaryEmail: data.primary_email || '',
77+
})
4678
}
4779

4880
return (
49-
<>
50-
<Panel
51-
className="mb-4 md:mb-8"
52-
title={<h5>Profile Information</h5>}
53-
footer={
54-
<FormActions
55-
form={formId}
56-
isSubmitting={isLoading}
57-
hasChanges={form.formState.isDirty}
58-
handleReset={() => form.reset()}
59-
/>
60-
}
61-
>
62-
<Form_Shadcn_ {...form}>
63-
<form
64-
id={formId}
65-
className="space-y-6 w-full p-4 md:p-8"
66-
onSubmit={form.handleSubmit(onSubmit)}
67-
>
81+
<Form_Shadcn_ {...form}>
82+
<form id={formId} className="space-y-6 w-full" onSubmit={form.handleSubmit(onSubmit)}>
83+
<Card className="mb-8">
84+
<CardHeader>Profile Information</CardHeader>
85+
<CardContent className="flex flex-col gap-y-2">
6886
<FormField_Shadcn_
6987
control={form.control}
7088
name="first_name"
7189
render={({ field }) => (
72-
<FormItem_Shadcn_ className="grid gap-2 md:grid md:grid-cols-12 space-y-0">
73-
<FormLabel_Shadcn_ className="flex flex-col space-y-2 col-span-4 text-sm justify-center text-foreground-light">
74-
First name
75-
</FormLabel_Shadcn_>
90+
<FormItemLayout label="First name" layout="horizontal">
7691
<FormControl_Shadcn_ className="col-span-8">
77-
<Input_Shadcn_ {...field} className="w-full" />
92+
<Input_Shadcn_ {...field} className="w-72" />
7893
</FormControl_Shadcn_>
79-
<FormMessage_Shadcn_ className="col-start-5 col-span-8" />
80-
</FormItem_Shadcn_>
94+
</FormItemLayout>
8195
)}
8296
/>
8397
<FormField_Shadcn_
8498
control={form.control}
8599
name="last_name"
86100
render={({ field }) => (
87-
<FormItem_Shadcn_ className="grid gap-2 md:grid md:grid-cols-12 space-y-0">
88-
<FormLabel_Shadcn_ className="flex flex-col space-y-2 col-span-4 text-sm justify-center text-foreground-light">
89-
Last name
90-
</FormLabel_Shadcn_>
101+
<FormItemLayout label="Last name" layout="horizontal">
102+
<FormControl_Shadcn_ className="col-span-8">
103+
<Input_Shadcn_ {...field} className="w-72" />
104+
</FormControl_Shadcn_>
105+
</FormItemLayout>
106+
)}
107+
/>
108+
<FormField_Shadcn_
109+
control={form.control}
110+
name="primary_email"
111+
render={({ field }) => (
112+
<FormItemLayout label="Primary email" layout="horizontal">
113+
<FormControl_Shadcn_ className="col-span-8">
114+
<div className="flex flex-col gap-1">
115+
<Select_Shadcn_
116+
value={field.value}
117+
onValueChange={field.onChange}
118+
disabled={profile?.is_sso_user}
119+
>
120+
<SelectTrigger_Shadcn_ className="col-span-8 w-72">
121+
<SelectValue_Shadcn_ placeholder="Select primary email" />
122+
</SelectTrigger_Shadcn_>
123+
<SelectContent_Shadcn_ className="col-span-8">
124+
{isIdentitiesSuccess &&
125+
dedupedIdentityEmails.map((email) => (
126+
<SelectItem_Shadcn_ key={email} value={email}>
127+
{email}
128+
</SelectItem_Shadcn_>
129+
))}
130+
</SelectContent_Shadcn_>
131+
</Select_Shadcn_>
132+
{(profile?.is_sso_user && (
133+
<p className="text-xs text-foreground-light">
134+
Primary email is managed by your SSO provider and cannot be changed here.
135+
</p>
136+
)) || (
137+
<p className="text-xs text-foreground-light">
138+
Primary email is used for account notifications.
139+
</p>
140+
)}
141+
</div>
142+
</FormControl_Shadcn_>
143+
</FormItemLayout>
144+
)}
145+
/>
146+
<FormField_Shadcn_
147+
control={form.control}
148+
name="username"
149+
render={({ field }) => (
150+
<FormItemLayout label="Username" layout="horizontal">
91151
<FormControl_Shadcn_ className="col-span-8">
92-
<Input_Shadcn_ {...field} className="w-full" />
152+
<div className="flex flex-col gap-1">
153+
<Input_Shadcn_ {...field} className="w-72" disabled={profile?.is_sso_user} />
154+
{(profile?.is_sso_user && (
155+
<p className="text-xs text-foreground-light">
156+
Username is managed by your SSO provider and cannot be changed here.
157+
</p>
158+
)) || (
159+
<p className="text-xs text-foreground-light">
160+
Username appears as a display name throughout the dashboard.
161+
</p>
162+
)}
163+
</div>
93164
</FormControl_Shadcn_>
94-
<FormMessage_Shadcn_ className="col-start-5 col-span-8" />
95-
</FormItem_Shadcn_>
165+
</FormItemLayout>
96166
)}
97167
/>
98-
</form>
99-
</Form_Shadcn_>
100-
</Panel>
101-
</>
168+
</CardContent>
169+
<CardFooter className="justify-end space-x-2">
170+
{form.formState.isDirty && (
171+
<Button type="default" onClick={() => form.reset()}>
172+
Cancel
173+
</Button>
174+
)}
175+
<Button
176+
type="primary"
177+
htmlType="submit"
178+
loading={isLoading || isIdentitiesLoading}
179+
disabled={!form.formState.isDirty}
180+
>
181+
Save
182+
</Button>
183+
</CardFooter>
184+
</Card>
185+
</form>
186+
</Form_Shadcn_>
102187
)
103188
}

apps/studio/components/interfaces/UserDropdown.tsx

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { Command, FlaskConical, Settings } from 'lucide-react'
1+
import { Command, FlaskConical, Loader2, Settings } from 'lucide-react'
22
import { useTheme } from 'next-themes'
33
import Link from 'next/link'
44
import { useRouter } from 'next/router'
55

66
import { ProfileImage } from 'components/ui/ProfileImage'
7+
import { useProfileIdentitiesQuery } from 'data/profile/profile-identities-query'
78
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
89
import { useSignOut } from 'lib/auth'
910
import { IS_PLATFORM } from 'lib/constants'
11+
import { getGitHubProfileImgUrl } from 'lib/github'
1012
import { useProfile } from 'lib/profile'
1113
import { useAppStateSnapshot } from 'state/app-state'
1214
import {
@@ -29,25 +31,40 @@ import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewConte
2931
export function UserDropdown() {
3032
const router = useRouter()
3133
const signOut = useSignOut()
32-
const { profile } = useProfile()
34+
const { profile, isLoading: isLoadingProfile } = useProfile()
3335
const { theme, setTheme } = useTheme()
3436
const appStateSnapshot = useAppStateSnapshot()
3537
const setCommandMenuOpen = useSetCommandMenuOpen()
3638
const { openFeaturePreviewModal } = useFeaturePreviewModal()
3739
const profileShowEmailEnabled = useIsFeatureEnabled('profile:show_email')
3840

41+
const { username, primary_email } = profile ?? {}
42+
43+
const { data, isLoading: isLoadingIdentities } = useProfileIdentitiesQuery()
44+
const isGitHubProfile = profile?.auth0_id.startsWith('github')
45+
const gitHubUsername = isGitHubProfile
46+
? (data?.identities ?? []).find((x) => x.provider === 'github')?.identity_data?.user_name
47+
: undefined
48+
const profileImageUrl = isGitHubProfile ? getGitHubProfileImgUrl(gitHubUsername) : undefined
49+
3950
return (
4051
<DropdownMenu>
4152
<DropdownMenuTrigger className="border flex-shrink-0 px-3" asChild>
4253
<Button
4354
type="default"
4455
className="[&>span]:flex px-0 py-0 rounded-full overflow-hidden h-8 w-8"
4556
>
46-
<ProfileImage
47-
alt={profile?.username}
48-
src={profile?.profileImageUrl}
49-
className="w-8 h-8 rounded-md"
50-
/>
57+
{isLoadingProfile || isLoadingIdentities ? (
58+
<div className="w-full h-full flex items-center justify-center">
59+
<Loader2 className="animate-spin text-foreground-lighter" size={16} />
60+
</div>
61+
) : (
62+
<ProfileImage
63+
alt={profile?.username}
64+
src={profileImageUrl}
65+
className="w-8 h-8 rounded-md"
66+
/>
67+
)}
5168
</Button>
5269
</DropdownMenuTrigger>
5370

@@ -57,18 +74,15 @@ export function UserDropdown() {
5774
<div className="px-2 py-1 flex flex-col gap-0 text-sm">
5875
{profile && (
5976
<>
60-
<span
61-
title={profile.username}
62-
className="w-full text-left text-foreground truncate"
63-
>
64-
{profile.username}
77+
<span title={username} className="w-full text-left text-foreground truncate">
78+
{username}
6579
</span>
66-
{profile.primary_email !== profile.username && profileShowEmailEnabled && (
80+
{primary_email !== username && profileShowEmailEnabled && (
6781
<span
68-
title={profile.primary_email}
82+
title={primary_email}
6983
className="w-full text-left text-foreground-light text-xs truncate"
7084
>
71-
{profile.primary_email}
85+
{primary_email}
7286
</span>
7387
)}
7488
</>

apps/studio/data/profile/profile-identities-query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function getProfileIdentities() {
1414
}
1515

1616
type ProfileIdentitiesData = {
17-
identities: UserIdentity[]
17+
identities: (UserIdentity & { email?: string })[]
1818
new_email?: string
1919
email_change_sent_at?: string
2020
}

apps/studio/data/profile/profile-update-mutation.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,23 @@ import { profileKeys } from './keys'
88
export type ProfileUpdateVariables = {
99
firstName: string
1010
lastName: string
11+
username: string
12+
primaryEmail: string
1113
}
1214

13-
export async function updateProfile({ firstName, lastName }: ProfileUpdateVariables) {
15+
export async function updateProfile({
16+
firstName,
17+
lastName,
18+
username,
19+
primaryEmail,
20+
}: ProfileUpdateVariables) {
1421
const { data, error } = await patch('/platform/profile', {
15-
body: { first_name: firstName, last_name: lastName },
22+
body: {
23+
first_name: firstName,
24+
last_name: lastName,
25+
username: username,
26+
primary_email: primaryEmail,
27+
},
1628
})
1729

1830
if (error) handleError(error)

0 commit comments

Comments
 (0)