diff --git a/packages/ui/public/locales/en.json b/packages/ui/public/locales/en.json index 585f73cad..717c5de1e 100644 --- a/packages/ui/public/locales/en.json +++ b/packages/ui/public/locales/en.json @@ -427,6 +427,7 @@ "files-backend-error.escapes-base": "The path is outside the allowed directory", "files-backend-error.invalid-base": "The path does not belong to a valid directory", "files-backend-error.invalid-filename": "The file name is not valid", + "files-backend-error.invalid-password": "The password contains characters that are not allowed", "files-backend-error.invalid-path": "The file path is not valid", "files-backend-error.mkdir-failed": "Failed to create the folder", "files-backend-error.move-failed": "Failed to move the item", @@ -434,6 +435,8 @@ "files-backend-error.operation-not-allowed": "This operation is not allowed", "files-backend-error.parent-not-directory": "The parent path is not a folder", "files-backend-error.parent-not-exist": "The parent folder does not exist", + "files-backend-error.password-too-long": "The password must be 127 characters or fewer", + "files-backend-error.password-too-short": "The password must be at least 8 characters", "files-backend-error.path-not-absolute": "The file path is not valid", "files-backend-error.share-already-exists": "This folder is already shared", "files-backend-error.share-name-generation-failed": "Could not generate a unique share name", @@ -469,7 +472,9 @@ "files-error.folder-already-exists": "A folder with this name already exists", "files-error.move": "Move failed: {{message}}", "files-error.remove-favorite": "Remove from favorites failed: {{message}}", + "files-error.regenerate-share-password": "Regenerate share password failed: {{message}}", "files-error.remove-share": "Remove shared folder failed: {{message}}", + "files-error.set-share-password": "Update share password failed: {{message}}", "files-error.rename": "Rename failed: {{message}}", "files-error.restore": "Restore failed: {{message}}", "files-error.trash": "Move to trash failed: {{message}}", @@ -977,6 +982,17 @@ "settings.file-sharing.home-shared-note": "Your entire \"{{homeDirectoryName}}\" folder is shared. Individual folders don't need separate sharing.", "settings.file-sharing.share-entire-home-dir": "Share your entire Home folder", "settings.file-sharing.share-entire-home-dir-description": "Access all files and folders in \"{{homeDirectoryName}}\" from other devices on your network", + "settings.file-sharing.password-action-cancel": "Cancel", + "settings.file-sharing.password-action-regenerate": "Regenerate", + "settings.file-sharing.password-action-save": "Save", + "settings.file-sharing.password-change-button": "Change", + "settings.file-sharing.password-custom-label": "New share password", + "settings.file-sharing.password-custom-title": "Set a custom share password", + "settings.file-sharing.password-min-length": "Password must be at least 8 characters.", + "settings.file-sharing.password-regenerate-description": "Generate a new random password. Any device currently connected will need to use the new password.", + "settings.file-sharing.password-regenerate-title": "Regenerate share password?", + "settings.file-sharing.password-toast-regenerated": "Share password regenerated", + "settings.file-sharing.password-toast-updated": "Share password updated", "settings.file-sharing.shared-folders": "Shared folders", "shortcut.add.already-exists": "This shortcut has already been added", "shortcut.add.custom-port": "Custom Port", diff --git a/packages/ui/src/features/files/components/dialogs/share-password-dialog/index.tsx b/packages/ui/src/features/files/components/dialogs/share-password-dialog/index.tsx new file mode 100644 index 000000000..c2c441db7 --- /dev/null +++ b/packages/ui/src/features/files/components/dialogs/share-password-dialog/index.tsx @@ -0,0 +1,120 @@ +import {useState} from 'react' +import {useTranslation} from 'react-i18next' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import {PasswordInput} from '@/components/ui/input' +import {toast} from '@/components/ui/toast' +import {useShares} from '@/features/files/hooks/use-shares' + +type Mode = 'custom' | 'regenerate' + +export function SharePasswordDialog({ + mode, + open, + onOpenChange, +}: { + mode: Mode + open: boolean + onOpenChange: (open: boolean) => void +}) { + const {t} = useTranslation() + const {setSharePassword, isSettingSharePassword, regenerateSharePassword, isRegeneratingSharePassword} = useShares() + + const [password, setPassword] = useState('') + const [error, setError] = useState() + + const reset = () => { + setPassword('') + setError(undefined) + } + + const handleSetCustom = async () => { + if (password.length < 8) { + setError(t('settings.file-sharing.password-min-length')) + return + } + try { + await setSharePassword({password}) + toast.success(t('settings.file-sharing.password-toast-updated')) + reset() + onOpenChange(false) + } catch { + // useShares already surfaces a toast on error + } + } + + const handleRegenerate = async () => { + try { + await regenerateSharePassword() + toast.success(t('settings.file-sharing.password-toast-regenerated')) + onOpenChange(false) + } catch { + // useShares already surfaces a toast on error + } + } + + const isCustom = mode === 'custom' + const isPending = isCustom ? isSettingSharePassword : isRegeneratingSharePassword + + return ( + { + if (!next) reset() + onOpenChange(next) + }} + > + + + + {isCustom + ? t('settings.file-sharing.password-custom-title') + : t('settings.file-sharing.password-regenerate-title')} + + {!isCustom && ( + + {t('settings.file-sharing.password-regenerate-description')} + + )} + + + {isCustom && ( +
+ { + setPassword(value) + if (error) setError(undefined) + }} + error={error} + autoFocus + /> +
+ )} + + + + {isCustom + ? t('settings.file-sharing.password-action-save') + : t('settings.file-sharing.password-action-regenerate')} + + {t('settings.file-sharing.password-action-cancel')} + +
+
+ ) +} diff --git a/packages/ui/src/features/files/hooks/use-shares.ts b/packages/ui/src/features/files/hooks/use-shares.ts index f20f927e9..470de2803 100644 --- a/packages/ui/src/features/files/hooks/use-shares.ts +++ b/packages/ui/src/features/files/hooks/use-shares.ts @@ -66,6 +66,28 @@ export function useShares() { }, }) + // Set custom share password mutation + const {mutateAsync: setSharePassword, isPending: isSettingSharePassword} = + trpcReact.files.setSharePassword.useMutation({ + onSuccess: async () => { + await utils.files.sharePassword.invalidate() + }, + onError: (error: RouterError) => { + toast.error(t('files-error.set-share-password', {message: getFilesErrorMessage(error.message)})) + }, + }) + + // Regenerate share password mutation + const {mutateAsync: regenerateSharePassword, isPending: isRegeneratingSharePassword} = + trpcReact.files.regenerateSharePassword.useMutation({ + onSuccess: async () => { + await utils.files.sharePassword.invalidate() + }, + onError: (error: RouterError) => { + toast.error(t('files-error.regenerate-share-password', {message: getFilesErrorMessage(error.message)})) + }, + }) + return { // Queries shares, @@ -82,5 +104,13 @@ export function useShares() { // Remove share removeShare, isRemovingShare, + + // Set custom share password + setSharePassword, + isSettingSharePassword, + + // Regenerate share password + regenerateSharePassword, + isRegeneratingSharePassword, } } diff --git a/packages/ui/src/features/files/utils/error-messages.ts b/packages/ui/src/features/files/utils/error-messages.ts index 708ebfcda..9b4992a55 100644 --- a/packages/ui/src/features/files/utils/error-messages.ts +++ b/packages/ui/src/features/files/utils/error-messages.ts @@ -25,6 +25,9 @@ export function getFilesErrorMessage(message: string): string { if (message.includes('[cant-find-root]')) return t('files-backend-error.cant-find-root') if (message.includes('[share-already-exists]')) return t('files-backend-error.share-already-exists') if (message.includes('[share-name-generation-failed]')) return t('files-backend-error.share-name-generation-failed') + if (message.includes('[password-too-short]')) return t('files-backend-error.password-too-short') + if (message.includes('[password-too-long]')) return t('files-backend-error.password-too-long') + if (message.includes('[invalid-password]')) return t('files-backend-error.invalid-password') return message } diff --git a/packages/ui/src/routes/settings/file-sharing.tsx b/packages/ui/src/routes/settings/file-sharing.tsx index e5b7f3ddb..0c1990945 100644 --- a/packages/ui/src/routes/settings/file-sharing.tsx +++ b/packages/ui/src/routes/settings/file-sharing.tsx @@ -10,6 +10,7 @@ import {Drawer, DrawerContent, DrawerHeader, DrawerScroller, DrawerTitle} from ' import {listClass} from '@/components/ui/list' import {Switch} from '@/components/ui/switch' import {HomeIcon} from '@/features/files/assets/home-icon' +import {SharePasswordDialog} from '@/features/files/components/dialogs/share-password-dialog' import {PlatformInstructions} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions' import { Platform, @@ -56,6 +57,7 @@ export default function FileSharingDrawerOrDialog() { const [selectedPlatform, setSelectedPlatform] = useState(platforms[0]) const [isAddFolderOpen, setAddFolderOpen] = useState(false) + const [passwordDialogMode, setPasswordDialogMode] = useState<'custom' | 'regenerate' | null>(null) // Stable-ordered list of all folders seen during this dialog session. // Seeded with initial shares on first load, then updated on toggle-off and add. @@ -299,6 +301,14 @@ export default function FileSharingDrawerOrDialog() { name={primaryName} sharename={primarySharename} /> +
+ + +
)} @@ -332,6 +342,13 @@ export default function FileSharingDrawerOrDialog() { <> {showChoiceScreen ? choiceScreen : activeScreen} {addFolderBrowser} + { + if (!open) setPasswordDialogMode(null) + }} + /> ) diff --git a/packages/umbreld/source/modules/files/routes.ts b/packages/umbreld/source/modules/files/routes.ts index 8791553bf..607070529 100644 --- a/packages/umbreld/source/modules/files/routes.ts +++ b/packages/umbreld/source/modules/files/routes.ts @@ -166,6 +166,16 @@ export default router({ // Get the share password sharePassword: privateProcedure.query(async ({ctx}) => ctx.umbreld.files.samba.getSharePassword()), + // Set a custom share password + setSharePassword: privateProcedure + .input(z.object({password: z.string().min(8).max(127)})) + .mutation(async ({ctx, input}) => ctx.umbreld.files.samba.setSharePassword(input.password)), + + // Regenerate the share password with a fresh random token + regenerateSharePassword: privateProcedure.mutation(async ({ctx}) => + ctx.umbreld.files.samba.regenerateSharePassword(), + ), + // Get shares shares: privateProcedure.query(async ({ctx}) => ctx.umbreld.files.samba.listShares()), diff --git a/packages/umbreld/source/modules/files/samba.integration.test.ts b/packages/umbreld/source/modules/files/samba.integration.test.ts index e5ff1c7ec..adc38ba1a 100644 --- a/packages/umbreld/source/modules/files/samba.integration.test.ts +++ b/packages/umbreld/source/modules/files/samba.integration.test.ts @@ -379,6 +379,44 @@ describe('sharePassword()', () => { }) }) +describe('setSharePassword()', () => { + test('throws invalid error without auth token', async () => { + await expect( + umbreld.unauthenticatedClient.files.setSharePassword.mutate({password: 'newpassword'}), + ).rejects.toThrow('Invalid token') + }) + + test('rejects passwords shorter than 8 characters', async () => { + await expect(umbreld.client.files.setSharePassword.mutate({password: 'short'})).rejects.toThrow() + }) + + test('persists a custom password and exposes it via sharePassword query', async () => { + const customPassword = 'my-strong-password' + await umbreld.client.files.setSharePassword.mutate({password: customPassword}) + + const stored = await umbreld.client.files.sharePassword.query() + expect(stored).toBe(customPassword) + }) +}) + +describe('regenerateSharePassword()', () => { + test('throws invalid error without auth token', async () => { + await expect(umbreld.unauthenticatedClient.files.regenerateSharePassword.mutate()).rejects.toThrow('Invalid token') + }) + + test('replaces the existing share password with a new one', async () => { + const original = await umbreld.client.files.sharePassword.query() + const regenerated = await umbreld.client.files.regenerateSharePassword.mutate() + + expect(regenerated).not.toBe(original) + expect(regenerated.length).toBe(32) + expect(/^[0-9a-f]{32}$/.test(regenerated)).toBe(true) + + const stored = await umbreld.client.files.sharePassword.query() + expect(stored).toBe(regenerated) + }) +}) + describe('samba', () => { async function createSmbClient(share: string) { const password = await umbreld.client.files.sharePassword.query() diff --git a/packages/umbreld/source/modules/files/samba.ts b/packages/umbreld/source/modules/files/samba.ts index 4bc208d52..802d5eb83 100644 --- a/packages/umbreld/source/modules/files/samba.ts +++ b/packages/umbreld/source/modules/files/samba.ts @@ -136,14 +136,49 @@ export default class Samba { return sharePassword } - // Applies the share password to the Samba user - async applySharePassword() { - const sharePassword = await this.getSharePassword() + // Applies the share password to the Samba user. + // Accepts an explicit password to apply before it's persisted, so callers + // can verify Samba accepts it before writing it to disk. + async applySharePassword(password?: string) { + const sharePassword = password ?? (await this.getSharePassword()) await $({ input: `${sharePassword}\n${sharePassword}\n`, })`smbpasswd -s -a umbrel` } + // Set a custom share password. + // Validates length, applies to Samba, and only then persists to the secrets + // file — so the stored password never diverges from what Samba actually has + // configured if smbpasswd fails. + async setSharePassword(newPassword: string) { + if (typeof newPassword !== 'string') throw new Error('[invalid-password]') + // Samba's smbpasswd accepts up to 127 chars in interactive mode. + // We require a minimum of 8 to avoid trivially weak passwords. + if (newPassword.length < 8) throw new Error('[password-too-short]') + if (newPassword.length > 127) throw new Error('[password-too-long]') + // Reject newlines — they would break the stdin protocol of smbpasswd. + if (/[\r\n]/.test(newPassword)) throw new Error('[invalid-password]') + + await this.applySharePassword(newPassword) + + const sharePasswordFile = `${this.#umbreld.dataDirectory}/secrets/share-password` + await fse.writeFile(sharePasswordFile, newPassword) + this.logger.log('Share password updated') + return true + } + + // Regenerate the share password with a fresh random token. + // Returns the new password so the UI can display it once. + async regenerateSharePassword() { + const newPassword = randomToken(128) + await this.applySharePassword(newPassword) + + const sharePasswordFile = `${this.#umbreld.dataDirectory}/secrets/share-password` + await fse.writeFile(sharePasswordFile, newPassword) + this.logger.log('Share password regenerated') + return newPassword + } + // Apply shares to Samba async applyShares({excludePaths}: {excludePaths?: string[]} = {}) { const shares = await this.#get()