Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/de/platform/member/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Das Menü trägt außerdem einen Organisationswechsler, wenn du zu mehr als eine

Der Profil-Abschnitt hält deinen **Anzeigenamen** und deine **E-Mail**. Der Anzeigename ist inline bearbeitbar; die Änderung speichert beim Verlassen des Felds und schlägt beim nächsten Render in jedem Chat und jeder Genehmigung durch. Die E-Mail ist schreibgeschützt — sie ist das, womit du dich angemeldet hast, und ein Wechsel läuft über den Support. Es gibt kein Avatar-Feld auf der Seite; Tale leitet einen Avatar aus den Initialen deines Anzeigenamens ab.

Der Sicherheits-Abschnitt hält einen einzelnen Knopf: **Passwort ändern**, wenn du dich mit E-Mail und Passwort registriert hast, **Passwort setzen**, wenn dein Konto über SSO föderiert ist und du ein Passwort als Rückfall hinzufügen willst. Beide Abläufe erzwingen die Passwort-Richtlinie der Organisation und zeigen die Regeln live, während du tippst. Der Zwei-Faktor-Abschnitt paart das Konto mit einer TOTP-App oder einem Hardware-Schlüssel und zeigt die Backup-Codes einmal bei der Einrichtung.
Der Sicherheits-Abschnitt hält einen einzelnen Knopf: **Passwort ändern**, wenn du dich mit E-Mail und Passwort registriert hast, **Passwort setzen**, wenn dein Konto über SSO föderiert ist und du ein Passwort als Rückfall hinzufügen willst. Beide Abläufe erzwingen die Passwort-Richtlinie der Organisation und zeigen die Regeln live, während du tippst, und ein falsches aktuelles Passwort wird direkt am Feld markiert statt als flüchtiger Fehler. Das Ändern deines Passworts meldet dich auf allen Geräten ab — der Dialog warnt dich, bevor du bestätigst, und du meldest dich anschließend mit dem neuen Passwort wieder an. Der Zwei-Faktor-Abschnitt paart das Konto mit einer TOTP-App oder einem Hardware-Schlüssel und zeigt die Backup-Codes einmal bei der Einrichtung.

## Personalisierung — eigene Anweisungen, Erinnerungen, Sprachausgabe

Expand Down
2 changes: 1 addition & 1 deletion docs/en/platform/member/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Open **Settings > Account**. Three sections sit on the page: **Profile**, **Secu

The Profile section holds your **display name** and your **email**. The display name is editable inline; the change saves on blur and propagates to every chat and approval the next time they render. Email is read-only — it is what you signed in with, and changing it goes through support. There is no avatar field on the page; Tale derives an avatar from your display name initials.

The Security section holds a single button: **Change password** if you signed up with email and password, **Set password** if your account is federated through SSO and you want to add a password as a fallback. Both flows enforce the org's password policy and surface the rules live as you type. The Two-factor section pairs the account with a TOTP app or a hardware key and shows the backup codes once at enrolment.
The Security section holds a single button: **Change password** if you signed up with email and password, **Set password** if your account is federated through SSO and you want to add a password as a fallback. Both flows enforce the org's password policy and surface the rules live as you type, and a wrong current password is flagged inline on the field rather than as a transient error. Changing your password signs you out of every device — the dialog warns you before you confirm, and you'll sign back in with the new password. The Two-factor section pairs the account with a TOTP app or a hardware key and shows the backup codes once at enrolment.

## Personalization — custom instructions, memories, voice output

Expand Down
2 changes: 1 addition & 1 deletion docs/fr/platform/member/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Ouvre **Paramètres > Compte**. Trois sections siègent sur la page : **Profil**

La section Profil tient ton **nom d'affichage** et ton **e-mail**. Le nom d'affichage s'édite en ligne ; la modification s'enregistre à la sortie du champ et se propage dans chaque chat et chaque approbation au prochain rendu. L'e-mail est en lecture seule — c'est avec lui que tu t'es connecté, et un changement passe par le support. Il n'y a pas de champ avatar sur la page ; Tale dérive un avatar à partir des initiales de ton nom d'affichage.

La section Sécurité tient un seul bouton : **Changer le mot de passe** si tu t'es inscrit avec e-mail et mot de passe, **Définir le mot de passe** si ton compte est fédéré via SSO et que tu veux ajouter un mot de passe comme repli. Les deux flux imposent la politique de mot de passe de l'org et affichent les règles en direct pendant que tu tapes. La section Deux-facteurs apparie le compte à une app TOTP ou à une clé matérielle et affiche les codes de secours une fois à l'enrôlement.
La section Sécurité tient un seul bouton : **Changer le mot de passe** si tu t'es inscrit avec e-mail et mot de passe, **Définir le mot de passe** si ton compte est fédéré via SSO et que tu veux ajouter un mot de passe comme repli. Les deux flux imposent la politique de mot de passe de l'org et affichent les règles en direct pendant que tu tapes, et un mot de passe actuel erroné est signalé directement sur le champ plutôt que comme une erreur passagère. Changer ton mot de passe te déconnecte de tous les appareils — le dialogue t'avertit avant que tu confirmes, et tu te reconnectes ensuite avec le nouveau mot de passe. La section Deux-facteurs apparie le compte à une app TOTP ou à une clé matérielle et affiche les codes de secours une fois à l'enrôlement.

## Personnalisation — instructions, mémoires, sortie vocale

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { Alert } from '@tale/ui/alert';
import { Button } from '@tale/ui/button';
import { Row } from '@tale/ui/layout';
import { SkeletonText } from '@tale/ui/skeleton';
import { Skeletonize, useSkeleton } from '@tale/ui/skeleton-context';
import { AlertTriangle } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
Expand All @@ -30,6 +32,7 @@ import { useToast } from '@/app/hooks/use-toast';
import { getEnv } from '@/lib/env';
import { useT } from '@/lib/i18n/client';
import { createPasswordSchema } from '@/lib/shared/schemas/password';
import { convexErrorCode } from '@/lib/utils/convex-error';

import { useUpdatePassword, useUpdateUserName } from '../hooks/mutations';
import { ChatsSection } from './chats-section';
Expand Down Expand Up @@ -296,6 +299,7 @@ function ChangePasswordDialog({ open, onOpenChange }: PasswordDialogProps) {
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
reset,
setError,
watch,
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
Expand All @@ -316,7 +320,18 @@ function ChangePasswordDialog({ open, onOpenChange }: PasswordDialogProps) {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
} catch {
} catch (error) {
// A wrong current password is an expected, recoverable failure — surface
// it as an inline field error on the current-password input (mirroring
// the 2FA / add-member flows) rather than a generic destructive toast
// (#1945). The backend raises a structured ConvexError for this case.
if (convexErrorCode(error) === 'INVALID_CURRENT_PASSWORD') {
setError('currentPassword', {
type: 'manual',
message: tAuth('changePassword.validation.currentIncorrect'),
});
return;
}
toast({
title: tToast('error.passwordChangeFailed'),
variant: 'destructive',
Expand Down Expand Up @@ -358,6 +373,13 @@ function ChangePasswordDialog({ open, onOpenChange }: PasswordDialogProps) {
isValid={isValid}
onSubmit={handleSubmit(onSubmit)}
>
<Alert
variant="warning"
icon={AlertTriangle}
title={tAuth('changePassword.warning.title')}
description={tAuth('changePassword.warning.description')}
/>

<Input
id="current-password"
type="password"
Expand Down
76 changes: 76 additions & 0 deletions services/platform/convex/users/update_user_password.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockChangePassword = vi.fn();
const mockSetPassword = vi.fn();
const mockRevokeOtherSessions = vi.fn();
const mockGetAuth = vi.fn();
const mockGetAuthUser = vi.fn();

// Hoisted so the same class backs both the `better-auth/api` mock the helper
// imports and the errors the tests construct — `instanceof APIError` only
// matches when both sides resolve to one class.
const { MockAPIError } = vi.hoisted(() => {
class HoistedAPIError extends Error {
status: string;
body: unknown;
constructor(status: string, body?: { code?: string; message?: string }) {
super(body?.message ?? status);
this.status = status;
this.body = body;
}
}
return { MockAPIError: HoistedAPIError };
});

vi.mock('better-auth/api', () => ({ APIError: MockAPIError }));

vi.mock('better-auth/crypto', () => ({
hashPassword: vi.fn(async () => 'hashed-password'),
}));

vi.mock('../auth', () => ({
authComponent: {
getAuth: (...args: unknown[]) => mockGetAuth(...args),
Expand Down Expand Up @@ -41,7 +64,15 @@ vi.mock('../audit_logs/helpers', () => ({

vi.mock('convex/values', () => {
const stub = () => 'validator';
class ConvexError extends Error {
data: unknown;
constructor(data: unknown) {
super(typeof data === 'string' ? data : 'ConvexError');
this.data = data;
}
}
return {
ConvexError,
v: {
string: stub,
number: stub,
Expand All @@ -62,6 +93,7 @@ vi.mock('../_generated/api', () => ({
betterAuth: {
adapter: {
findMany: 'betterAuth:adapter:findMany',
updateMany: 'betterAuth:adapter:updateMany',
},
},
},
Expand Down Expand Up @@ -100,6 +132,7 @@ describe('updateUserPassword', () => {
api: {
changePassword: mockChangePassword,
setPassword: mockSetPassword,
revokeOtherSessions: mockRevokeOtherSessions,
},
},
headers: MOCK_HEADERS,
Expand Down Expand Up @@ -158,6 +191,49 @@ describe('updateUserPassword', () => {
});
});

it('raises a structured INVALID_CURRENT_PASSWORD error when the current password is wrong', async () => {
mockHasCredentialAccount.mockResolvedValue(true);
mockChangePassword.mockRejectedValue(
new MockAPIError('UNAUTHORIZED', {
code: 'INVALID_PASSWORD',
message: 'Invalid password',
}),
);
const ctx = createMockCtx();
const handler = await getHandler();

await expect(
handler(ctx as never, {
currentPassword: 'WrongP@ss1',
newPassword: VALID_PASSWORD,
}),
).rejects.toMatchObject({
data: { code: 'INVALID_CURRENT_PASSWORD' },
});
});

it('forced change for credential users revokes all other sessions', async () => {
mockHasCredentialAccount.mockResolvedValue(true);
mockRevokeOtherSessions.mockResolvedValue(undefined);
const ctx = createMockCtx();
ctx.runQuery.mockResolvedValue({
page: [{ _id: 'account_1', userId: 'user_1', providerId: 'credential' }],
});
ctx.runMutation.mockResolvedValue(undefined);
const handler = await getHandler();

await handler(ctx as never, {
newPassword: VALID_PASSWORD,
trigger: 'forced',
});

expect(mockRevokeOtherSessions).toHaveBeenCalledWith({
headers: MOCK_HEADERS,
});
// Forced change updates the credential directly, never via changePassword.
expect(mockChangePassword).not.toHaveBeenCalled();
});

it('throws when currentPassword missing for credential users', async () => {
mockHasCredentialAccount.mockResolvedValue(true);
const ctx = createMockCtx();
Expand Down
55 changes: 47 additions & 8 deletions services/platform/convex/users/update_user_password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* Update user password - Business logic
*/

import { APIError } from 'better-auth/api';
import { hashPassword } from 'better-auth/crypto';
import { ConvexError } from 'convex/values';

import {
isPasswordValid,
Expand Down Expand Up @@ -76,14 +78,34 @@ export async function updateUserPassword(
if (!args.currentPassword) {
throw new Error('Current password is required');
}
await auth.api.changePassword({
body: {
currentPassword: args.currentPassword,
newPassword: args.newPassword,
revokeOtherSessions: true,
},
headers,
});
try {
// `revokeOtherSessions: true` invalidates every OTHER session for this
// user server-side, so a stolen/forgotten device can't keep its login
// after a password change. The caller's own session is also signed out
// client-side once this resolves (see ChangePasswordDialog), so the net
// effect is a global sign-out (#1945).
await auth.api.changePassword({
body: {
currentPassword: args.currentPassword,
newPassword: args.newPassword,
revokeOtherSessions: true,
},
headers,
});
} catch (err) {
// A wrong current password is an expected, recoverable failure. Better
// Auth raises an APIError with code INVALID_PASSWORD; re-raise it as a
// structured ConvexError so the change-password form can show an inline
// field error on the current-password input instead of a generic
// destructive toast (#1945).
if (isInvalidPasswordError(err)) {
throw new ConvexError({
code: 'INVALID_CURRENT_PASSWORD',
message: 'Current password is incorrect',
});
}
throw err;
}
} else {
await auth.api.setPassword({
body: {
Expand Down Expand Up @@ -120,6 +142,23 @@ export async function updateUserPassword(
);
}

/**
* True when a Better Auth API error represents a rejected current password
* (wrong password on `changePassword`). Matches on the stable `INVALID_PASSWORD`
* error code, with a message fallback for resilience across Better Auth
* versions. Other API errors (network, validation, etc.) return false so they
* keep bubbling up as generic failures.
*/
function isInvalidPasswordError(err: unknown): boolean {
if (!(err instanceof APIError)) return false;
const body: unknown = err.body;
if (isRecord(body) && getString(body, 'code') === 'INVALID_PASSWORD') {
return true;
}
const message = err instanceof Error ? err.message : '';
return /invalid password/i.test(message);
}

async function forcedResetCredentialPassword(
ctx: MutationCtx,
userId: string,
Expand Down
3 changes: 3 additions & 0 deletions services/platform/messages/de-CH.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
"changePassword": {
"requirements": {
"uppercase": "Ein Grossbuchstabe"
},
"warning": {
"description": "Beim Ändern deines Passworts wirst du auf allen Geräten abgemeldet. Du musst dich anschliessend mit deinem neuen Passwort erneut anmelden."
}
},
"forcedChange": {
Expand Down
5 changes: 5 additions & 0 deletions services/platform/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,13 @@
},
"validation": {
"currentRequired": "Aktuelles Passwort ist erforderlich",
"currentIncorrect": "Aktuelles Passwort ist falsch",
"confirmRequired": "Bitte bestätige dein neues Passwort",
"mismatch": "Passwörter stimmen nicht überein"
},
"warning": {
"title": "Du wirst überall abgemeldet",
"description": "Beim Ändern deines Passworts wirst du auf allen Geräten abgemeldet. Du musst dich anschließend mit deinem neuen Passwort erneut anmelden."
}
},
"forcedChange": {
Expand Down
5 changes: 5 additions & 0 deletions services/platform/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,13 @@
},
"validation": {
"currentRequired": "Current password is required",
"currentIncorrect": "Current password is incorrect",
"confirmRequired": "Please confirm your new password",
"mismatch": "Passwords do not match"
},
"warning": {
"title": "You'll be signed out everywhere",
"description": "Changing your password signs you out of all devices. You'll need to sign in again with your new password."
}
},
"forcedChange": {
Expand Down
5 changes: 5 additions & 0 deletions services/platform/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -574,8 +574,13 @@
},
"validation": {
"currentRequired": "Le mot de passe actuel est requis",
"currentIncorrect": "Le mot de passe actuel est incorrect",
"confirmRequired": "Confirme ton nouveau mot de passe",
"mismatch": "Les mots de passe ne correspondent pas"
},
"warning": {
"title": "Tu seras déconnecté partout",
"description": "Changer ton mot de passe te déconnecte de tous les appareils. Tu devras te reconnecter avec ton nouveau mot de passe."
}
},
"forcedChange": {
Expand Down