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
16 changes: 16 additions & 0 deletions packages/ui/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -427,13 +427,16 @@
"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",
"files-backend-error.not-enough-space": "Not enough storage space available",
"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",
Expand Down Expand Up @@ -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}}",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | undefined>()

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 (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) reset()
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isCustom
? t('settings.file-sharing.password-custom-title')
: t('settings.file-sharing.password-regenerate-title')}
</AlertDialogTitle>
{!isCustom && (
<AlertDialogDescription>
{t('settings.file-sharing.password-regenerate-description')}
</AlertDialogDescription>
)}
</AlertDialogHeader>

{isCustom && (
<div className='px-1 pb-2'>
<PasswordInput
label={t('settings.file-sharing.password-custom-label')}
value={password}
onValueChange={(value) => {
setPassword(value)
if (error) setError(undefined)
}}
error={error}
autoFocus
/>
</div>
)}

<AlertDialogFooter>
<AlertDialogAction
className='px-6'
disabled={isPending || (isCustom && password.length < 8)}
onClick={isCustom ? handleSetCustom : handleRegenerate}
>
{isCustom
? t('settings.file-sharing.password-action-save')
: t('settings.file-sharing.password-action-regenerate')}
</AlertDialogAction>
<AlertDialogCancel>{t('settings.file-sharing.password-action-cancel')}</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
30 changes: 30 additions & 0 deletions packages/ui/src/features/files/hooks/use-shares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -82,5 +104,13 @@ export function useShares() {
// Remove share
removeShare,
isRemovingShare,

// Set custom share password
setSharePassword,
isSettingSharePassword,

// Regenerate share password
regenerateSharePassword,
isRegeneratingSharePassword,
}
}
3 changes: 3 additions & 0 deletions packages/ui/src/features/files/utils/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
17 changes: 17 additions & 0 deletions packages/ui/src/routes/settings/file-sharing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,6 +57,7 @@ export default function FileSharingDrawerOrDialog() {

const [selectedPlatform, setSelectedPlatform] = useState<Platform>(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.
Expand Down Expand Up @@ -299,6 +301,14 @@ export default function FileSharingDrawerOrDialog() {
name={primaryName}
sharename={primarySharename}
/>
<div className='flex flex-wrap items-center gap-2 px-1'>
<Button size='sm' variant='secondary' onClick={() => setPasswordDialogMode('custom')}>
{t('settings.file-sharing.password-change-button')}
</Button>
<Button size='sm' variant='secondary' onClick={() => setPasswordDialogMode('regenerate')}>
{t('settings.file-sharing.password-action-regenerate')}
</Button>
</div>
</div>
</motion.div>
)}
Expand Down Expand Up @@ -332,6 +342,13 @@ export default function FileSharingDrawerOrDialog() {
<>
{showChoiceScreen ? choiceScreen : activeScreen}
{addFolderBrowser}
<SharePasswordDialog
mode={passwordDialogMode ?? 'custom'}
open={passwordDialogMode !== null}
onOpenChange={(open) => {
if (!open) setPasswordDialogMode(null)
}}
/>
</>
)

Expand Down
10 changes: 10 additions & 0 deletions packages/umbreld/source/modules/files/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),

Expand Down
38 changes: 38 additions & 0 deletions packages/umbreld/source/modules/files/samba.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 38 additions & 3 deletions packages/umbreld/source/modules/files/samba.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down