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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions packages/ui/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,15 @@
"shortcut.open": "Open",
"shortcut.remove": "Remove shortcut",
"show-details": "Show details",
"power-schedule.title": "Power schedule",
"power-schedule.description": "Schedule daily shutdown and restart times",
"power-schedule.configure": "Configure",
"power-schedule.subtitle": "Set daily times for automatic shutdown and restart.",
"power-schedule.note": "Schedules run only while Umbrel is online. This does not power on a shut down device.",
"power-schedule.shutdown.title": "Daily shutdown",
"power-schedule.shutdown.description": "Shut down Umbrel at the selected time",
"power-schedule.restart.title": "Daily restart",
"power-schedule.restart.description": "Restart Umbrel at the selected time",
"shut-down": "Shut down",
"shut-down.complete": "Shutdown complete",
"shut-down.complete-text": "You can now unplug your device from the power.",
Expand Down
31 changes: 27 additions & 4 deletions packages/ui/src/providers/global-system-state/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {toast} from '@/components/ui/toast'
import {usePrefixedLocalStorage} from '@/hooks/use-prefixed-local-storage'
import {useJwt} from '@/modules/auth/use-auth'
import {MigratingCover, useMigrate} from '@/providers/global-system-state/migrate'
import {RestartingCover, useRestart} from '@/providers/global-system-state/restart'
import {ShuttingDownCover, useShutdown} from '@/providers/global-system-state/shutdown'
import {RouterError, RouterOutput, trpcReact} from '@/trpc/trpc'
import {RestartingCover, useRestart, useRestartWithPassword} from '@/providers/global-system-state/restart'
import {ShuttingDownCover, useShutdown, useShutdownWithPassword} from '@/providers/global-system-state/shutdown'
import {RouterError, RouterInput, RouterOutput, trpcReact} from '@/trpc/trpc'
import {MS_PER_SECOND} from '@/utils/date-time'
import {assertUnreachable, IS_DEV} from '@/utils/misc'

Expand All @@ -24,7 +24,9 @@ type SystemStatus = RouterOutput['system']['status']

const GlobalSystemStateContext = createContext<{
shutdown: () => void
shutdownWithPassword: (input: RouterInput['system']['shutdownWithPassword']) => Promise<boolean>
restart: () => void
restartWithPassword: (input: RouterInput['system']['restartWithPassword']) => Promise<boolean>
update: () => void
migrate: () => void
reset: (password: string) => void
Expand Down Expand Up @@ -73,6 +75,14 @@ export function GlobalSystemStateProvider({children}: {children: ReactNode}) {
// Prevent logout/redirect when error occurs
setShouldLogoutOnRunning(false)
}

const onPowerError = async () => {
setTriggered(false)
setShouldLogoutOnRunning(false)
setStartShutdownTimer(false)
setShutdownComplete(false)
setRouterError(null)
}
const getError = () => routerError
const clearError = () => setRouterError(null)
// Allow external code to suppress errors (e.g., RAID setup doing its own restart flow)
Expand All @@ -97,7 +107,9 @@ export function GlobalSystemStateProvider({children}: {children: ReactNode}) {

// TODO: handle `onError` for other actions than reset?
const restart = useRestart({onMutate, onSuccess})
const restartWithPassword = useRestartWithPassword({onMutate, onSuccess, onError: onPowerError})
const shutdown = useShutdown({onMutate, onSuccess})
const shutdownWithPassword = useShutdownWithPassword({onMutate, onSuccess, onError: onPowerError})
const update = useUpdate({onMutate, onSuccess})
const migrate = useMigrate({onMutate, onSuccess})
const reset = useReset({onMutate, onError})
Expand Down Expand Up @@ -248,7 +260,18 @@ export function GlobalSystemStateProvider({children}: {children: ReactNode}) {
case 'running': {
return (
<GlobalSystemStateContext
value={{shutdown, restart, update, migrate, reset, getError, clearError, suppressErrors}}
value={{
shutdown,
shutdownWithPassword,
restart,
restartWithPassword,
update,
migrate,
reset,
getError,
clearError,
suppressErrors,
}}
>
{children}
{debugInfo}
Expand Down
19 changes: 18 additions & 1 deletion packages/ui/src/providers/global-system-state/restart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {useTranslation} from 'react-i18next'

import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'
import {Loading} from '@/components/ui/loading'
import {trpcReact} from '@/trpc/trpc'
import {type RouterError, trpcReact} from '@/trpc/trpc'

export function useRestart({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {
const restartMut = trpcReact.system.restart.useMutation({
Expand All @@ -14,6 +14,23 @@ export function useRestart({onMutate, onSuccess}: {onMutate?: () => void; onSucc
return restart
}

export function useRestartWithPassword({
onMutate,
onSuccess,
onError,
}: {
onMutate?: () => void
onSuccess?: (didWork: boolean) => void
onError?: (error: RouterError) => void
}) {
const restartMut = trpcReact.system.restartWithPassword.useMutation({
onMutate,
onSuccess,
onError,
})
return restartMut.mutateAsync
}

export function RestartingCover() {
const {t} = useTranslation()
return (
Expand Down
19 changes: 18 additions & 1 deletion packages/ui/src/providers/global-system-state/shutdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {useTranslation} from 'react-i18next'

import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'
import {Loading} from '@/components/ui/loading'
import {trpcReact} from '@/trpc/trpc'
import {type RouterError, trpcReact} from '@/trpc/trpc'

export function useShutdown({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {
const shutdownMut = trpcReact.system.shutdown.useMutation({
Expand All @@ -14,6 +14,23 @@ export function useShutdown({onMutate, onSuccess}: {onMutate?: () => void; onSuc
return shutdown
}

export function useShutdownWithPassword({
onMutate,
onSuccess,
onError,
}: {
onMutate?: () => void
onSuccess?: (didWork: boolean) => void
onError?: (error: RouterError) => void
}) {
const shutdownMut = trpcReact.system.shutdownWithPassword.useMutation({
onMutate,
onSuccess,
onError,
})
return shutdownMut.mutateAsync
}

export function ShuttingDownCover() {
const {t} = useTranslation()
return (
Expand Down
132 changes: 128 additions & 4 deletions packages/ui/src/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import {useState} from 'react'
import {useEffect, useState} from 'react'
import {useTranslation} from 'react-i18next'
import {TbCircleCheckFilled} from 'react-icons/tb'
import {RiRestartLine, RiShutDownLine} from 'react-icons/ri'
import {useLocation} from 'react-router-dom'

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {PasswordInput} from '@/components/ui/input'
import {PinInput} from '@/components/ui/pin-input'
import {formGroupClass, Layout, primaryButtonProps} from '@/layouts/bare/shared'
import {formGroupClass, Layout, primaryButtonProps, secondaryButtonClasss} from '@/layouts/bare/shared'
import {cn} from '@/lib/utils'
import {useAuth} from '@/modules/auth/use-auth'
import {useGlobalSystemState} from '@/providers/global-system-state/index'
import {trpcReact} from '@/trpc/trpc'

type Step = 'password' | '2fa'
type PowerStep = 'password' | '2fa'
type PowerAction = 'shutdown' | 'restart'

export default function Login() {
const {t} = useTranslation()
Expand Down Expand Up @@ -68,11 +74,18 @@ export default function Login() {
</AlertDialog>
)

const powerFooter = (
<>
<PowerActionDialog action='restart' />
<PowerActionDialog action='shutdown' />
</>
)

switch (step) {
case 'password': {
return (
<>
<Layout title={t('login.title')} subTitle={t('login.subtitle')}>
<Layout title={t('login.title')} subTitle={t('login.subtitle')} footer={powerFooter}>
<form className='flex w-full flex-col items-center gap-5 px-4 md:px-0' onSubmit={handleSubmitPassword}>
<div className={cn(formGroupClass, 'max-w-[280px]')}>
<PasswordInput
Expand All @@ -95,7 +108,7 @@ export default function Login() {
case '2fa': {
return (
<>
<Layout title={t('login-2fa.title')} subTitle={t('login-2fa.subtitle')}>
<Layout title={t('login-2fa.title')} subTitle={t('login-2fa.subtitle')} footer={powerFooter}>
<form className='flex w-full flex-col items-center gap-5 px-4 md:px-0' onSubmit={handleSubmitPassword}>
<PinInput autoFocus length={6} onCodeCheck={handleSubmit2fa} />
</form>
Expand All @@ -106,3 +119,114 @@ export default function Login() {
}
}
}

function PowerActionDialog({action}: {action: PowerAction}) {
const {t} = useTranslation()
const {shutdownWithPassword, restartWithPassword} = useGlobalSystemState()
const powerAction = action === 'shutdown' ? shutdownWithPassword : restartWithPassword
const [open, setOpen] = useState(false)
const [step, setStep] = useState<PowerStep>('password')
const [password, setPassword] = useState('')
const [passwordError, setPasswordError] = useState('')
const [isPending, setIsPending] = useState(false)

useEffect(() => {
if (!open) {
setStep('password')
setPassword('')
setPasswordError('')
setIsPending(false)
}
}, [open])

const titleKey = action === 'shutdown' ? 'shut-down.confirm.title' : 'restart.confirm.title'
const submitKey = action === 'shutdown' ? 'shut-down.confirm.submit' : 'restart.confirm.submit'
const triggerKey = action === 'shutdown' ? 'shut-down' : 'restart'
const ActionIcon = action === 'shutdown' ? RiShutDownLine : RiRestartLine

const handlePasswordSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!password) return
setPasswordError('')
setIsPending(true)
try {
await powerAction({password})
setOpen(false)
} catch (error) {
const message = (error as {message?: string})?.message ?? ''
if (message === 'Missing 2FA code') {
setPasswordError('')
setStep('2fa')
return
}
setPasswordError(message || t('something-went-wrong'))
} finally {
setIsPending(false)
}
}

const handleSubmit2fa = async (totpToken: string) => {
try {
await powerAction({password, totpToken})
setOpen(false)
return true
} catch (error) {
const message = (error as {message?: string})?.message ?? ''
if (message === 'Incorrect password') {
setPasswordError(message)
setStep('password')
}
return false
}
}

return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<button className={secondaryButtonClasss} type='button'>
{t(triggerKey)}
</button>
</AlertDialogTrigger>
<AlertDialogContent>
{step === 'password' ? (
<form className='flex flex-col gap-5' onSubmit={handlePasswordSubmit}>
<AlertDialogHeader icon={ActionIcon}>
<AlertDialogTitle>{t(titleKey)}</AlertDialogTitle>
</AlertDialogHeader>
<div className={cn(formGroupClass, 'mx-auto w-full max-w-[280px]')}>
<PasswordInput
autoFocus
label={t('login.password-label')}
value={password}
onValueChange={(value) => {
setPasswordError('')
setPassword(value)
}}
error={passwordError}
/>
</div>
<AlertDialogFooter>
<AlertDialogAction variant='destructive' type='submit' disabled={!password || isPending}>
{t(submitKey)}
</AlertDialogAction>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
</AlertDialogFooter>
</form>
) : (
<div className='flex flex-col gap-5'>
<AlertDialogHeader icon={ActionIcon}>
<AlertDialogTitle>{t(titleKey)}</AlertDialogTitle>
<AlertDialogDescription>{t('login-2fa.subtitle')}</AlertDialogDescription>
</AlertDialogHeader>
<div className='mx-auto'>
<PinInput autoFocus length={6} onCodeCheck={handleSubmit2fa} />
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
</AlertDialogFooter>
</div>
)}
</AlertDialogContent>
</AlertDialog>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Tb2Fa,
TbArrowBigRightLines,
TbCircleArrowUp,
TbClock,
TbColumns3,
TbHistory,
TbLanguage,
Expand Down Expand Up @@ -186,6 +187,12 @@ export function SettingsContentMobile() {
description={t('settings.file-sharing.description')}
onClick={() => navigate('file-sharing')}
/>
<ListRowMobile
icon={TbClock}
title={t('power-schedule.title')}
description={t('power-schedule.description')}
onClick={() => navigate('power-schedule')}
/>
{isUmbrelPro && (
<ListRowMobile
icon={TbColumns3}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
RiPulseLine,
RiRestartLine,
RiShutDownLine,
RiTimeLine,
RiUserLine,
} from 'react-icons/ri'
import {TbColumns3, TbHistory, TbServer, TbSettings, TbSettingsMinus, TbShare, TbTool, TbWifi} from 'react-icons/tb'
Expand Down Expand Up @@ -190,6 +191,11 @@ export function SettingsContent() {
{t('settings.file-sharing.configure')}
</IconButton>
</ListRow>
<ListRow title={t('power-schedule.title')} description={t('power-schedule.description')}>
<IconButton icon={RiTimeLine} onClick={() => navigate('power-schedule')}>
{t('power-schedule.configure')}
</IconButton>
</ListRow>
{/* Backups */}
<ListRow title={t('backups')} description={t('backups-description')}>
<div className='flex flex-wrap gap-2 pt-3'>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const ChangeNameDialog = React.lazy(() => import('@/routes/settings/change-name'
const ChangePasswordDialog = React.lazy(() => import('@/routes/settings/change-password'))
const RestartDialog = React.lazy(() => import('@/routes/settings/restart'))
const ShutdownDialog = React.lazy(() => import('@/routes/settings/shutdown'))
const PowerScheduleDialog = React.lazy(() => import('@/routes/settings/power-schedule'))
const TroubleshootDialog = React.lazy(() => import('@/routes/settings/troubleshoot/index'))
const TerminalDialog = React.lazy(() => import('@/routes/settings/terminal/index'))
const DeviceInfoDialog = React.lazy(() => import('@/routes/settings/device-info'))
Expand Down Expand Up @@ -157,6 +158,7 @@ export function Settings() {
<Route path='/software-update/confirm' Component={SoftwareUpdateConfirmDialog} />
<Route path='/file-sharing' Component={FileSharingDrawerOrDialog} />
<Route path='/advanced/:advancedSelection?' Component={AdvancedSettingsDrawerOrDialog} />
<Route path='/power-schedule' Component={PowerScheduleDialog} />
<Route path='/storage/*' Component={StorageManagerDialog} />
</Routes>
<QueryStringDialog />
Expand Down
Loading