From b7bacb352f6a1b030cc24e91218ee727291f55ae Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Fri, 13 Mar 2026 20:43:34 -0400 Subject: [PATCH] feat: add change password to web and mobile settings Add a change password feature using Better Auth's built-in changePassword endpoint. On web, a dialog opens from a new card in the Account section. On mobile, a dedicated screen is pushed from a new SettingsRow. Both use listAccounts to detect credential-based accounts and only show the option when the user has a password and password login is enabled (respecting OIDC-only configurations). Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 4 + AGENTS.md | 1 + apps/native/.gitignore | 1 + .../app/(tabs)/(settings)/change-password.tsx | 203 +++++++++++++++++ .../src/app/(tabs)/(settings)/index.tsx | 24 ++ .../settings/change-password-section.tsx | 214 ++++++++++++++++++ apps/web/src/routes/_app/settings.tsx | 2 + 7 files changed, 449 insertions(+) create mode 100644 apps/native/src/app/(tabs)/(settings)/change-password.tsx create mode 100644 apps/web/src/components/settings/change-password-section.tsx diff --git a/.env.example b/.env.example index a0ec44d..99a3715 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,10 @@ BETTER_AUTH_URL=http://localhost:3000 # Log verbosity: error, warn, info, debug (default: info) # LOG_LEVEL=info +# ─── Update Checks ──────────────────────────────────────────────────── +# Base URL for the public API used for update checks and other centralized features +# PUBLIC_API_URL=https://public-api.sofa.watch + # ─── Image Caching ───────────────────────────────────────────────────── # Set IMAGE_CACHE_ENABLED to "false" to use TMDB CDN directly (default: enabled) # IMAGE_CACHE_ENABLED=true diff --git a/AGENTS.md b/AGENTS.md index 75bf3db..7d395ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ Required: `TMDB_API_READ_ACCESS_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. Optional: - `DATA_DIR` — Root for DB + cache (default `./data`). `DATABASE_URL` and `CACHE_DIR` derived from it but overridable. - `TMDB_API_BASE_URL`, `TMDB_IMAGE_BASE_URL` — Override TMDB endpoints. +- `PUBLIC_API_URL` — Base URL for centralized public API (default: `https://public-api.sofa.watch`). Used for update checks. - `IMAGE_CACHE_ENABLED` — Default `true`. Set `false` for direct TMDB CDN URLs. - `LOG_LEVEL` — `error`/`warn`/`info`/`debug` (default: `info`). - OIDC: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER_URL`, `OIDC_PROVIDER_NAME`, `OIDC_AUTO_REGISTER`, `DISABLE_PASSWORD_LOGIN`. diff --git a/apps/native/.gitignore b/apps/native/.gitignore index 525476c..33a6c68 100644 --- a/apps/native/.gitignore +++ b/apps/native/.gitignore @@ -9,6 +9,7 @@ npm-debug.* *.mobileprovision *.orig.* *.tar.gz +*.ipa web-build/ # macOS diff --git a/apps/native/src/app/(tabs)/(settings)/change-password.tsx b/apps/native/src/app/(tabs)/(settings)/change-password.tsx new file mode 100644 index 0000000..921f4a5 --- /dev/null +++ b/apps/native/src/app/(tabs)/(settings)/change-password.tsx @@ -0,0 +1,203 @@ +import { useForm } from "@tanstack/react-form"; +import { Stack, useRouter } from "expo-router"; +import { useRef, useState } from "react"; +import { Alert, type TextInput, View } from "react-native"; +import Animated, { FadeInDown } from "react-native-reanimated"; +import { z } from "zod"; +import { Button, ButtonLabel } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { Text } from "@/components/ui/text"; +import { + FieldError, + Input, + Label, + TextField, +} from "@/components/ui/text-field"; +import { authClient } from "@/lib/auth-client"; +import { toast } from "@/lib/toast"; +import * as Haptics from "@/utils/haptics"; + +const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +function formatFormErrors(errors: unknown): string | null { + if (!errors) return null; + if (typeof errors === "string") return errors; + if (typeof errors === "object") { + const first = Object.values(errors as Record) + .flat() + .find((e) => e.message); + if (first) return first.message; + } + return null; +} + +export default function ChangePasswordScreen() { + const router = useRouter(); + const newPasswordRef = useRef(null); + const confirmPasswordRef = useRef(null); + const [revokeOtherSessions, setRevokeOtherSessions] = useState(false); + + const form = useForm({ + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + validators: { onSubmit: changePasswordSchema }, + onSubmit: async ({ value, formApi }) => { + try { + const result = await authClient.changePassword({ + currentPassword: value.currentPassword, + newPassword: value.newPassword, + revokeOtherSessions, + }); + if (result.error) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert( + "Error", + result.error.message ?? "Failed to change password", + ); + return; + } + + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + toast.success("Password updated"); + formApi.reset(); + router.back(); + } catch { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert("Error", "Something went wrong"); + } + }, + }); + + return ( + + + + ({ + isSubmitting: state.isSubmitting, + validationError: formatFormErrors(state.errorMap.onSubmit), + })} + > + {({ isSubmitting, validationError }) => ( + + {validationError && ( + + {validationError} + + )} + + + + {(field) => ( + + + newPasswordRef.current?.focus()} + /> + + )} + + + + + + {(field) => ( + + + + confirmPasswordRef.current?.focus() + } + /> + + )} + + + + + + {(field) => ( + + + + + )} + + + + + + Sign out of other sessions + + + + + + + + + )} + + + ); +} diff --git a/apps/native/src/app/(tabs)/(settings)/index.tsx b/apps/native/src/app/(tabs)/(settings)/index.tsx index 7f26762..041c173 100644 --- a/apps/native/src/app/(tabs)/(settings)/index.tsx +++ b/apps/native/src/app/(tabs)/(settings)/index.tsx @@ -8,6 +8,7 @@ import { IconDeviceMobileCog, IconDots, IconLink, + IconLock, IconLogout, IconPhoto, IconServer, @@ -63,6 +64,21 @@ export default function SettingsScreen() { const isAdmin = session?.user?.role === "admin"; const serverUrl = getServerUrl(); + const authConfig = useQuery(orpc.system.authConfig.queryOptions()); + const { data: accounts } = useQuery({ + queryKey: ["auth", "listAccounts"], + queryFn: async () => { + const result = await authClient.listAccounts(); + return result.data; + }, + }); + const hasPassword = + accounts?.some( + (a: { providerId: string }) => a.providerId === "credential", + ) ?? false; + const showPasswordOption = + hasPassword && !(authConfig.data?.passwordLoginDisabled ?? true); + const systemHealth = useQuery({ ...orpc.system.health.queryOptions(), enabled: isAdmin, @@ -318,6 +334,14 @@ export default function SettingsScreen() { )} + {showPasswordOption && ( + push("/(tabs)/(settings)/change-password")} + /> + )} + { + const result = await authClient.listAccounts(); + return result.data; + }, + }); + + const isLoading = authConfigPending || accountsPending; + const passwordLoginDisabled = authConfig?.passwordLoginDisabled ?? true; + const hasPassword = + accounts?.some( + (a: { providerId: string }) => a.providerId === "credential", + ) ?? false; + + // Only show for users who have a password account and password login is enabled + if (isLoading || passwordLoginDisabled || !hasPassword) return null; + + return ( + + +
+
+
+ +
+
+ Change password + Update your account password +
+
+ +
+
+
+ ); +} + +function ChangePasswordDialog() { + const [open, setOpen] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [revokeOtherSessions, setRevokeOtherSessions] = useState(false); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + function resetForm() { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setRevokeOtherSessions(false); + setError(""); + } + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + if (!nextOpen) resetForm(); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!currentPassword) { + setError("Current password is required"); + return; + } + if (newPassword.length < 8) { + setError("New password must be at least 8 characters"); + return; + } + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + setIsSubmitting(true); + try { + const result = await authClient.changePassword({ + currentPassword, + newPassword, + revokeOtherSessions, + }); + if (result.error) { + setError(result.error.message ?? "Failed to change password"); + return; + } + toast.success("Password updated"); + handleOpenChange(false); + } catch { + setError("Something went wrong"); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + + + Change password + + Enter your current password and choose a new one. + + + +
+ {error && ( + + + {error} + + )} + +
+ + setCurrentPassword(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + setNewPassword(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + setRevokeOtherSessions(checked === true) + } + disabled={isSubmitting} + /> + +
+ + + }> + Cancel + + + +
+
+
+ ); +} diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx index c588c6f..e67a226 100644 --- a/apps/web/src/routes/_app/settings.tsx +++ b/apps/web/src/routes/_app/settings.tsx @@ -8,6 +8,7 @@ import { AccountSection } from "@/components/settings/account-section"; import { BackupRestoreSection } from "@/components/settings/backup-restore-section"; import { BackupScheduleSection } from "@/components/settings/backup-schedule-section"; import { BackupSection } from "@/components/settings/backup-section"; +import { ChangePasswordSection } from "@/components/settings/change-password-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { RegistrationSection } from "@/components/settings/registration-section"; import { SettingsShell } from "@/components/settings/settings-shell"; @@ -82,6 +83,7 @@ function SettingsPage() { role: session.user.role ?? undefined, }} /> + {isAdmin && ( <>