|
@@ -36,6 +36,13 @@ import {
|
|
|
import { PasskeySettings } from '@/features/settings/Components/passkey-settings'
|
|
import { PasskeySettings } from '@/features/settings/Components/passkey-settings'
|
|
|
import { QRCodeSVG } from 'qrcode.react'
|
|
import { QRCodeSVG } from 'qrcode.react'
|
|
|
import { requestEmailChange } from '@/features/settings/Actions/accountActions'
|
|
import { requestEmailChange } from '@/features/settings/Actions/accountActions'
|
|
|
|
|
+import { setAccountPassword } from '@/features/settings/Actions/setAccountPassword'
|
|
|
|
|
+import {
|
|
|
|
|
+ ACCOUNT_HAS_PASSWORD,
|
|
|
|
|
+ PASSWORD_MIN_LENGTH,
|
|
|
|
|
+} from '@/features/settings/Schema/accountPassword'
|
|
|
|
|
+import { REAUTH_FRESH_SESSION_MINUTES, reauthErrorMessageKey } from '@/lib/reauth'
|
|
|
|
|
+import { signInUrlFor } from '@/lib/signed-out'
|
|
|
import { useCooldown } from '@/hooks/use-cooldown'
|
|
import { useCooldown } from '@/hooks/use-cooldown'
|
|
|
import { useRouter } from 'next/navigation'
|
|
import { useRouter } from 'next/navigation'
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useTranslations } from 'next-intl'
|
|
@@ -44,12 +51,19 @@ export function AccountSettings({
|
|
|
twoFactorEnabled: initialTwoFactorEnabled,
|
|
twoFactorEnabled: initialTwoFactorEnabled,
|
|
|
emailVerified: initialEmailVerified,
|
|
emailVerified: initialEmailVerified,
|
|
|
emailVerificationRequired,
|
|
emailVerificationRequired,
|
|
|
|
|
+ hasPassword: initialHasPassword,
|
|
|
devices,
|
|
devices,
|
|
|
signature,
|
|
signature,
|
|
|
}: {
|
|
}: {
|
|
|
twoFactorEnabled: boolean
|
|
twoFactorEnabled: boolean
|
|
|
emailVerified: boolean
|
|
emailVerified: boolean
|
|
|
emailVerificationRequired: boolean
|
|
emailVerificationRequired: boolean
|
|
|
|
|
+ /**
|
|
|
|
|
+ * False for an account that signed up with Google or Microsoft and never
|
|
|
|
|
+ * chose a password. It gets "set a password" instead of "change password",
|
|
|
|
|
+ * which would have no current password to check.
|
|
|
|
|
+ */
|
|
|
|
|
+ hasPassword: boolean
|
|
|
devices: SignedInDevice[]
|
|
devices: SignedInDevice[]
|
|
|
/** The caller's saved signature for this workshop, as a data URI. */
|
|
/** The caller's saved signature for this workshop, as a data URI. */
|
|
|
signature: string | null
|
|
signature: string | null
|
|
@@ -57,6 +71,7 @@ export function AccountSettings({
|
|
|
const { data: session } = useSession()
|
|
const { data: session } = useSession()
|
|
|
const router = useRouter()
|
|
const router = useRouter()
|
|
|
const t = useTranslations('settings')
|
|
const t = useTranslations('settings')
|
|
|
|
|
+ const tCommon = useTranslations('common')
|
|
|
const [name, setName] = useState('')
|
|
const [name, setName] = useState('')
|
|
|
const [email, setEmail] = useState('')
|
|
const [email, setEmail] = useState('')
|
|
|
|
|
|
|
@@ -74,6 +89,10 @@ export function AccountSettings({
|
|
|
const [newPassword, setNewPassword] = useState('')
|
|
const [newPassword, setNewPassword] = useState('')
|
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
|
const [savingPassword, setSavingPassword] = useState(false)
|
|
const [savingPassword, setSavingPassword] = useState(false)
|
|
|
|
|
+ const [hasPassword, setHasPassword] = useState(initialHasPassword)
|
|
|
|
|
+ /** The server wants a newer sign-in before it will set a first password. */
|
|
|
|
|
+ const [signInAgainNeeded, setSignInAgainNeeded] = useState(false)
|
|
|
|
|
+ const [leavingToSignIn, setLeavingToSignIn] = useState(false)
|
|
|
|
|
|
|
|
// 2FA state
|
|
// 2FA state
|
|
|
const [twoFactorPassword, setTwoFactorPassword] = useState('')
|
|
const [twoFactorPassword, setTwoFactorPassword] = useState('')
|
|
@@ -258,6 +277,63 @@ export function AccountSettings({
|
|
|
setSavingPassword(false)
|
|
setSavingPassword(false)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const handleSetPassword = async () => {
|
|
|
|
|
+ if (newPassword !== confirmPassword) {
|
|
|
|
|
+ toast.error(t('account.passwordsNoMatch'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (newPassword.length < PASSWORD_MIN_LENGTH) {
|
|
|
|
|
+ toast.error(t('account.passwordMinLength'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ setSavingPassword(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await setAccountPassword({ newPassword })
|
|
|
|
|
+ if (result.success) {
|
|
|
|
|
+ toast.success(t('account.passwordSet'))
|
|
|
|
|
+ setHasPassword(true)
|
|
|
|
|
+ setSignInAgainNeeded(false)
|
|
|
|
|
+ setNewPassword('')
|
|
|
|
|
+ setConfirmPassword('')
|
|
|
|
|
+ router.refresh()
|
|
|
|
|
+ } else if (reauthErrorMessageKey(result.error) === 'account.reauthSignInAgainPassword') {
|
|
|
|
|
+ // Said in the card, with the way to do it, rather than in a toast
|
|
|
|
|
+ // that is gone before the person has read what to do next.
|
|
|
|
|
+ setSignInAgainNeeded(true)
|
|
|
|
|
+ } else if (result.error === ACCOUNT_HAS_PASSWORD) {
|
|
|
|
|
+ toast.info(t('account.passwordAlreadySet'))
|
|
|
|
|
+ setHasPassword(true)
|
|
|
|
|
+ setNewPassword('')
|
|
|
|
|
+ setConfirmPassword('')
|
|
|
|
|
+ } else {
|
|
|
|
|
+ toast.error(t('account.failedSetPassword'))
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ toast.error(t('account.failedSetPassword'))
|
|
|
|
|
+ }
|
|
|
|
|
+ setSavingPassword(false)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // The sign-in page sends anyone with a session straight on, so signing in
|
|
|
|
|
+ // again means signing out first. It comes back here afterwards.
|
|
|
|
|
+ const handleSignInAgain = async () => {
|
|
|
|
|
+ setLeavingToSignIn(true)
|
|
|
|
|
+ let signedOut = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await authClient.signOut()
|
|
|
|
|
+ if (result?.error) signedOut = false
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ signedOut = false
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!signedOut) {
|
|
|
|
|
+ // Without a sign-out the sign-in page would bounce straight back here.
|
|
|
|
|
+ setLeavingToSignIn(false)
|
|
|
|
|
+ toast.error(tCommon('errors.unexpected'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ window.location.assign(signInUrlFor('/settings/account'))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// const initials = session?.user?.name
|
|
// const initials = session?.user?.name
|
|
|
// ? session.user.name
|
|
// ? session.user.name
|
|
|
// .split(' ')
|
|
// .split(' ')
|
|
@@ -371,29 +447,39 @@ export function AccountSettings({
|
|
|
<SignatureCard initial={signature} />
|
|
<SignatureCard initial={signature} />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Change Password */}
|
|
|
|
|
|
|
+ {/* Change the password, or set the first one. An account that signed up
|
|
|
|
|
+ with Google has none, and asking it for a current password is a
|
|
|
|
|
+ question with no right answer. */}
|
|
|
<AppCard
|
|
<AppCard
|
|
|
id="account-password"
|
|
id="account-password"
|
|
|
icon={KeyRound}
|
|
icon={KeyRound}
|
|
|
- title={t('account.changePasswordTitle')}
|
|
|
|
|
|
|
+ title={hasPassword ? t('account.changePasswordTitle') : t('account.setPasswordTitle')}
|
|
|
contentClassName="space-y-4"
|
|
contentClassName="space-y-4"
|
|
|
>
|
|
>
|
|
|
- <p className="text-sm text-muted-foreground">{t('account.changePasswordDescription')}</p>
|
|
|
|
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
|
|
+ {hasPassword
|
|
|
|
|
+ ? t('account.changePasswordDescription')
|
|
|
|
|
+ : t('account.setPasswordDescription')}
|
|
|
|
|
+ </p>
|
|
|
<div className="grid gap-4 sm:grid-cols-1 max-w-sm">
|
|
<div className="grid gap-4 sm:grid-cols-1 max-w-sm">
|
|
|
- <div className="space-y-2">
|
|
|
|
|
- <Label htmlFor="currentPassword">{t('account.currentPassword')}</Label>
|
|
|
|
|
- <Input
|
|
|
|
|
- id="currentPassword"
|
|
|
|
|
- type="password"
|
|
|
|
|
- value={currentPassword}
|
|
|
|
|
- onChange={(e) => setCurrentPassword(e.target.value)}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ {hasPassword && (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <Label htmlFor="currentPassword">{t('account.currentPassword')}</Label>
|
|
|
|
|
+ <Input
|
|
|
|
|
+ id="currentPassword"
|
|
|
|
|
+ type="password"
|
|
|
|
|
+ autoComplete="current-password"
|
|
|
|
|
+ value={currentPassword}
|
|
|
|
|
+ onChange={(e) => setCurrentPassword(e.target.value)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
<div className="space-y-2">
|
|
<div className="space-y-2">
|
|
|
<Label htmlFor="newPassword">{t('account.newPassword')}</Label>
|
|
<Label htmlFor="newPassword">{t('account.newPassword')}</Label>
|
|
|
<Input
|
|
<Input
|
|
|
id="newPassword"
|
|
id="newPassword"
|
|
|
type="password"
|
|
type="password"
|
|
|
|
|
+ autoComplete="new-password"
|
|
|
value={newPassword}
|
|
value={newPassword}
|
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
|
/>
|
|
/>
|
|
@@ -403,20 +489,43 @@ export function AccountSettings({
|
|
|
<Input
|
|
<Input
|
|
|
id="confirmPassword"
|
|
id="confirmPassword"
|
|
|
type="password"
|
|
type="password"
|
|
|
|
|
+ autoComplete="new-password"
|
|
|
value={confirmPassword}
|
|
value={confirmPassword}
|
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ {!hasPassword &&
|
|
|
|
|
+ (signInAgainNeeded ? (
|
|
|
|
|
+ <div role="alert" className="max-w-xl space-y-3 rounded-lg border p-4">
|
|
|
|
|
+ <p className="text-sm font-medium">{t('account.reauthSignInAgainPassword')}</p>
|
|
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
|
|
+ {t('account.setPasswordSignInAgainHint', {
|
|
|
|
|
+ minutes: REAUTH_FRESH_SESSION_MINUTES,
|
|
|
|
|
+ })}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <Button variant="outline" onClick={handleSignInAgain} disabled={leavingToSignIn}>
|
|
|
|
|
+ {leavingToSignIn && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
|
|
|
+ {t('account.signInAgain')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <p className="max-w-xl text-xs text-muted-foreground">
|
|
|
|
|
+ {t('account.setPasswordFreshHint', { minutes: REAUTH_FRESH_SESSION_MINUTES })}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ ))}
|
|
|
<Separator />
|
|
<Separator />
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-3">
|
|
|
- <Button onClick={handleChangePassword} disabled={savingPassword}>
|
|
|
|
|
|
|
+ <Button
|
|
|
|
|
+ onClick={hasPassword ? handleChangePassword : handleSetPassword}
|
|
|
|
|
+ disabled={savingPassword}
|
|
|
|
|
+ >
|
|
|
{savingPassword ? (
|
|
{savingPassword ? (
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
) : (
|
|
) : (
|
|
|
<KeyRound className="mr-2 h-4 w-4" />
|
|
<KeyRound className="mr-2 h-4 w-4" />
|
|
|
)}
|
|
)}
|
|
|
- {t('account.changePassword')}
|
|
|
|
|
|
|
+ {hasPassword ? t('account.changePassword') : t('account.setPassword')}
|
|
|
</Button>
|
|
</Button>
|
|
|
</div>
|
|
</div>
|
|
|
</AppCard>
|
|
</AppCard>
|
|
@@ -459,8 +568,14 @@ export function AccountSettings({
|
|
|
) : (
|
|
) : (
|
|
|
<>
|
|
<>
|
|
|
<p className="text-sm text-muted-foreground">{t('account.twoFactorEnablePrompt')}</p>
|
|
<p className="text-sm text-muted-foreground">{t('account.twoFactorEnablePrompt')}</p>
|
|
|
|
|
+ {/* Turning it on asks for the account's password, and an account
|
|
|
|
|
+ that signed up with Google has none: the dialog used to take
|
|
|
|
|
+ them as far as a password field they could not fill in. */}
|
|
|
|
|
+ {!hasPassword && (
|
|
|
|
|
+ <p className="text-sm text-muted-foreground">{t('account.twoFactorNeedsPassword')}</p>
|
|
|
|
|
+ )}
|
|
|
<Separator />
|
|
<Separator />
|
|
|
- <Button onClick={handleOpenSetupDialog}>
|
|
|
|
|
|
|
+ <Button onClick={handleOpenSetupDialog} disabled={!hasPassword}>
|
|
|
<Shield className="mr-2 h-4 w-4" />
|
|
<Shield className="mr-2 h-4 w-4" />
|
|
|
{t('account.enable2FA')}
|
|
{t('account.enable2FA')}
|
|
|
</Button>
|
|
</Button>
|
|
@@ -504,7 +619,7 @@ export function AccountSettings({
|
|
|
</div>
|
|
</div>
|
|
|
<DialogFooter>
|
|
<DialogFooter>
|
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
|
|
- Cancel
|
|
|
|
|
|
|
+ {t('account.cancel')}
|
|
|
</Button>
|
|
</Button>
|
|
|
<Button onClick={handleEnable2FA} disabled={enabling2FA}>
|
|
<Button onClick={handleEnable2FA} disabled={enabling2FA}>
|
|
|
{enabling2FA && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{enabling2FA && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|