Files
fadymak 17dde3d324 feat(account): let OAuth-only users add a password to their account (#49057)
Allows a user to add a password which automatically creates and email
identity to enable email + password authentication for OAuth-only
accounts.

Gated behind a feature flag: `enableAccountPassword`

When a user does not have an email identity, allow them to set a
password:

<img width="762" height="284" alt="Screenshot 2026-08-13 at 14 52 53"
src="https://github.com/user-attachments/assets/70b4883a-ed38-488a-a1b7-908caa112a0b"
/>

Password modal:

<img width="519" height="423" alt="Screenshot 2026-08-13 at 14 56 57"
src="https://github.com/user-attachments/assets/56ac370d-9e6c-4b05-986f-3aa9a51826c2"
/>

Email identity has been created, allow unlinking and/or updating email
address or password:

<img width="764" height="285" alt="Screenshot 2026-08-13 at 14 55 04"
src="https://github.com/user-attachments/assets/5d9d7692-f4e3-4638-958d-15fefad01333"
/>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * OAuth-only accounts can set a password from Sign-in methods.
* Added password visibility controls, validation guidance, and success
or error feedback.
  * Sign-in methods display the account email when available.

* **Updates**
* Renamed “Account identities” to “Sign-in methods” throughout account
preferences.
* Standardized password requirements across password setup and reset
forms.
* Setting a password refreshes the current session and signs out other
sessions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-14 14:56:23 +02:00

287 lines
11 KiB
TypeScript

import type { Provider } from '@supabase/auth-js'
import { useFlag } from 'common'
import dayjs from 'dayjs'
import { Edit, Unlink } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
Card,
CardContent,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { parseRedirectMessage, shouldShowAddPasswordRow } from './AccountIdentities.utils'
import { AddPasswordRow } from './AddPasswordRow'
import {
ChangeEmailAddressForm,
GitHubChangeEmailAddress,
SSOChangeEmailAddress,
} from './ChangeEmailAddress'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { IdentityProviderIcon } from '@/components/ui/ProviderIcon'
import { useProfileIdentitiesQuery } from '@/data/profile/profile-identities-query'
import { useUnlinkIdentityMutation } from '@/data/profile/profile-unlink-identity-mutation'
import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders'
import { captureCriticalError } from '@/lib/error-reporting'
import {
buildProviderAuthRedirect,
getProviderDisplay,
type ExternalIdentityProviderConfig,
} from '@/lib/external-identity-providers'
import { getErrorMessage } from '@/lib/get-error-message'
import { auth, buildPathWithParams } from '@/lib/gotrue'
export const AccountIdentities = () => {
const router = useRouter()
const { data, isPending: isLoading, isSuccess } = useProfileIdentitiesQuery()
const enabledProviders = useEnabledIdentityProviders()
const connectableExternalProviders = useMemo(
() => enabledProviders.filter((provider) => provider.showInAccountPreferences),
[enabledProviders]
)
const isAddAccountPasswordEnabled = useFlag('enableAccountPassword')
const identities = data?.identities ?? []
const showAddPasswordRow =
isAddAccountPasswordEnabled && shouldShowAddPasswordRow({ identities, email: data?.email })
const isChangeExpired = data?.email_change_sent_at
? dayjs().utc().diff(dayjs(data?.email_change_sent_at).utc(), 'minute') > 10
: false
const [selectedProviderUnlink, setSelectedProviderUnlink] = useState<string>()
const [selectedProviderUpdateEmail, setSelectedProviderUpdateEmail] = useState<string>()
const [linkingProviderId, setLinkingProviderId] = useState<string>()
const message = parseRedirectMessage(router.asPath)
const unlinkedExternalProviders = connectableExternalProviders.filter((provider) => {
return !identities.some(
(identity) => identity.provider === provider.authProvider || identity.provider === provider.id
)
})
const { mutate: unlinkIdentity, isPending: isUnlinking } = useUnlinkIdentityMutation({
onSuccess: () => {
toast.success(`Successfully unlinked ${getProviderName(selectedProviderUnlink)} identity!`)
setSelectedProviderUnlink(undefined)
},
})
const getProviderName = (provider?: string) =>
provider ? getProviderDisplay(provider).displayName : undefined
const getConfiguredExternalProvider = (provider: string) =>
connectableExternalProviders.find(
({ id, authProvider }) => provider === id || provider === authProvider
)
const onConfirmUnlinkIdentity = async () => {
const identity = identities.find((i) => i.provider === selectedProviderUnlink)
if (identity) unlinkIdentity(identity)
}
const onLinkExternalProvider = async (provider: ExternalIdentityProviderConfig) => {
setLinkingProviderId(provider.id)
try {
const redirectTo = buildPathWithParams(buildProviderAuthRedirect(provider.id, '/account/me'))
const { error } = await auth.linkIdentity({
provider: provider.authProvider as Provider,
options: { redirectTo, scopes: provider.scopes },
})
if (error) throw error
} catch (error: unknown) {
const message = getErrorMessage(error) ?? 'Unknown error'
toast.error(`Failed to link ${provider.displayName} identity: ${message}`)
captureCriticalError(
error instanceof Error ? error : new Error(message),
`link ${provider.displayName} identity`
)
setLinkingProviderId(undefined)
}
}
useEffect(() => {
if (message) toast.success(message)
}, [message])
return (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Sign-in methods</PageSectionTitle>
<PageSectionDescription>
Manage the providers linked to your Supabase account and update their details.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
{isLoading && (
<CardContent>
<ShimmeringLoader />
</CardContent>
)}
{isSuccess && (
<div className="divide-y">
{showAddPasswordRow && !!data.email && <AddPasswordRow email={data.email} />}
{identities.map((identity) => {
const { identity_id, provider } = identity
const username = identity.identity_data?.user_name
const providerDisplay = getProviderDisplay(provider)
const providerName = providerDisplay.displayName
const configuredProvider = getConfiguredExternalProvider(provider)
const canUpdateEmail = !configuredProvider
return (
<CardContent key={identity_id} className="flex justify-between items-center py-4">
<div className="flex gap-x-4">
<IdentityProviderIcon display={providerDisplay} size={30} />
<div>
<div className="flex items-center gap-x-2">
<p className="text-sm capitalize">{providerName}</p>
{provider === 'email' && data.new_email && !isChangeExpired && (
<Tooltip>
<TooltipTrigger className="flex items-center">
<Badge variant="default">Pending change</Badge>
</TooltipTrigger>
<TooltipContent>Changing to {data.new_email}</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-sm text-foreground-lighter">
{!!username ? <span>{username} · </span> : null}
{identity.email}
</p>
</div>
</div>
<div className="flex items-center gap-x-1">
{provider === 'email' && (
<Button asChild variant="default">
<Link href="/reset-password?type=change">Change password</Link>
</Button>
)}
{canUpdateEmail && (
<ButtonTooltip
variant="text"
icon={<Edit />}
className="w-7"
onClick={() => setSelectedProviderUpdateEmail(provider)}
tooltip={{ content: { side: 'bottom', text: 'Update email address' } }}
/>
)}
{identities.length > 1 && (
<ButtonTooltip
variant="text"
icon={<Unlink />}
className="w-7"
onClick={() => setSelectedProviderUnlink(provider)}
tooltip={{ content: { side: 'bottom', text: 'Unlink identity' } }}
/>
)}
</div>
</CardContent>
)
})}
{unlinkedExternalProviders.map((provider) => {
const providerDisplay = getProviderDisplay(provider.authProvider)
return (
<CardContent key={provider.id} className="flex justify-between items-center py-4">
<div className="flex gap-x-4">
<IdentityProviderIcon display={providerDisplay} size={30} />
<div>
<p className="text-sm">{provider.displayName}</p>
<p className="text-sm text-foreground-lighter">
Link your {provider.displayName} account to sign in with{' '}
{provider.displayName}
</p>
</div>
</div>
<Button
variant="default"
loading={linkingProviderId === provider.id}
disabled={!!linkingProviderId}
onClick={() => onLinkExternalProvider(provider)}
>
Connect
</Button>
</CardContent>
)
})}
</div>
)}
</Card>
<Dialog
open={!!selectedProviderUpdateEmail}
onOpenChange={(open: boolean) => {
if (!open) setSelectedProviderUpdateEmail(undefined)
}}
>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>
{selectedProviderUpdateEmail !== 'email'
? `Updating email address for ${getProviderName(selectedProviderUpdateEmail)} identity`
: 'Update email address'}
</DialogTitle>
</DialogHeader>
{selectedProviderUpdateEmail === 'github' ? (
<GitHubChangeEmailAddress />
) : selectedProviderUpdateEmail?.startsWith('sso') ? (
<SSOChangeEmailAddress />
) : (
<ChangeEmailAddressForm onClose={() => setSelectedProviderUpdateEmail(undefined)} />
)}
</DialogContent>
</Dialog>
<ConfirmationModal
variant="warning"
size="small"
loading={isUnlinking}
visible={!!selectedProviderUnlink}
title={`Unlink ${getProviderName(selectedProviderUnlink)} identity`}
onCancel={() => setSelectedProviderUnlink(undefined)}
onConfirm={onConfirmUnlinkIdentity}
confirmLabel="Unlink identity"
confirmLabelLoading="Unlinking identity"
alert={{
base: { variant: 'warning' },
title: `Confirm to disconnect your ${getProviderName(selectedProviderUnlink)} identity`,
description:
'After disconnecting, you will only be able to sign in with your remaining identities.',
}}
/>
</PageSectionContent>
</PageSection>
)
}