diff --git a/packages/ui/public/locales/en.json b/packages/ui/public/locales/en.json index ea1bd50d4..3fb6fee73 100644 --- a/packages/ui/public/locales/en.json +++ b/packages/ui/public/locales/en.json @@ -47,7 +47,7 @@ "app-store.category.media": "Media", "app-store.category.networking": "Networking", "app-store.category.social": "Social", - "app-store.description": "Your app update settings", + "app-store.description": "Registries, preferences", "app-store.discover.temporarily-unavailable-description": "Browse categories above or search to find apps", "app-store.discover.temporarily-unavailable-title": "Featured content temporarily unavailable", "app-store.menu.community-app-stores": "Community App Stores", @@ -961,6 +961,14 @@ "search": "Search", "settings": "Settings", "settings.app-store-preferences.title": "App Store Preferences", + "settings.app-store-preferences.registry-credentials.title": "Private Registry Credentials", + "settings.app-store-preferences.registry-credentials.description": "Login credentials for private container registries (e.g. ghcr.io). Used when installing community apps that require private images.", + "settings.app-store-preferences.registry-credentials.add-button": "Add registry", + "settings.app-store-preferences.registry-credentials.registry-placeholder": "Registry host (e.g. ghcr.io)", + "settings.app-store-preferences.registry-credentials.username-placeholder": "Username", + "settings.app-store-preferences.registry-credentials.token-placeholder": "Token / Password", + "settings.app-store-preferences.registry-credentials.save": "Save", + "settings.app-store-preferences.registry-credentials.remove": "Remove", "settings.contact-support": "Need help? Contact support.", "settings.file-sharing": "File sharing", "settings.file-sharing.add-folder": "Add", diff --git a/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx b/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx index 84e0183c3..532633c5a 100644 --- a/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx +++ b/packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx @@ -1,71 +1,115 @@ import {useState} from 'react' -import {TbChevronRight} from 'react-icons/tb' +import {useTranslation} from 'react-i18next' +import {TbLock, TbPlus, TbTrash} from 'react-icons/tb' -import {ChevronDown} from '@/components/chevron-down' import {Button} from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' +import {Input, PasswordInput} from '@/components/ui/input' import {listClass, listItemClass} from '@/components/ui/list' -import {SegmentedControl} from '@/components/ui/segmented-control' -import {Switch} from '@/components/ui/switch' +import {trpcReact} from '@/trpc/trpc' export function AppStorePreferencesContent() { - const tabs = [ - {id: 'auto-update', label: 'Auto-update'}, - {id: 'notifications', label: 'Notifications'}, - {id: 'uninstall', label: 'Uninstall'}, - ] - const [activeTab, setActiveTab] = useState(tabs[0].id) + const {t} = useTranslation() + const utils = trpcReact.useUtils() + + const credentialsQ = trpcReact.appStore.getRegistryCredentials.useQuery() + const setMut = trpcReact.appStore.setRegistryCredential.useMutation({ + onSuccess: () => utils.appStore.getRegistryCredentials.invalidate(), + }) + const removeMut = trpcReact.appStore.removeRegistryCredential.useMutation({ + onSuccess: () => utils.appStore.getRegistryCredentials.invalidate(), + }) + + const [showForm, setShowForm] = useState(false) + const [registry, setRegistry] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + + const resetForm = () => { + setRegistry('') + setUsername('') + setPassword('') + setShowForm(false) + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + await setMut.mutateAsync({registry: registry.trim(), username: username.trim(), password}) + resetForm() + } + + const credentials = credentialsQ.data ?? [] return ( - <> -
- -
- Single value selector - - - - - - English - French - - -
- +
+
+

+ + {t('settings.app-store-preferences.registry-credentials.title')} +

+

+ {t('settings.app-store-preferences.registry-credentials.description')} +

- -
- -
-
-
- Lighting node - -
-
- Lighting node - + + {credentials.length > 0 && ( +
+ {credentials.map(({registry: reg}) => ( +
+ {reg} + +
+ ))}
-
- + )} + + {!showForm && ( + + )} + + {showForm && ( +
+ + + +
+ + +
+ + )} +
) } diff --git a/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx b/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx index 1ca68e36d..9805944e6 100644 --- a/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx +++ b/packages/ui/src/routes/settings/_components/settings-content-mobile.tsx @@ -10,6 +10,7 @@ import { TbServer, TbSettingsMinus, TbShare, + TbShoppingBag, TbTool, TbUser, TbWifi, @@ -210,12 +211,12 @@ export function SettingsContentMobile() { description={t('backups-description')} onClick={() => navigate('backups')} /> - {/* navigate(linkToDialog('app-store-preferences'))} - /> */} + />
- {/* + navigate(linkToDialog('app-store-preferences'))}> {t('preferences')} - */} + navigate('troubleshoot')}> {t('troubleshoot')} diff --git a/packages/umbreld/package-lock.json b/packages/umbreld/package-lock.json index eda46097a..ff453eef3 100644 --- a/packages/umbreld/package-lock.json +++ b/packages/umbreld/package-lock.json @@ -1,12 +1,12 @@ { "name": "umbreld", - "version": "1.5.0", + "version": "1.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "umbreld", - "version": "1.5.0", + "version": "1.7.1", "license": "PolyForm Noncommercial License 1.0.0", "dependencies": { "@homebridge/dbus-native": "github:getumbrel/dbus-native#types", diff --git a/packages/umbreld/source/modules/apps/routes.ts b/packages/umbreld/source/modules/apps/routes.ts index 613d8f2b7..dd7ec0b27 100644 --- a/packages/umbreld/source/modules/apps/routes.ts +++ b/packages/umbreld/source/modules/apps/routes.ts @@ -1,7 +1,10 @@ import z from 'zod' +import fse from 'fs-extra' import {router, privateProcedure} from '../server/trpc/trpc.js' +const DOCKER_CONFIG_PATH = '/root/.docker/config.json' + export const appStore = router({ // Returns the app store registry registry: privateProcedure.query(async ({ctx}) => ctx.appStore.registry()), @@ -23,6 +26,54 @@ export const appStore = router({ }), ) .mutation(async ({ctx, input}) => ctx.appStore.removeRepository(input.url)), + + // List saved private registry hostnames (no passwords returned) + getRegistryCredentials: privateProcedure.query(async () => { + try { + const config = await fse.readJson(DOCKER_CONFIG_PATH) + const auths: Record = config?.auths ?? {} + return Object.keys(auths) + .filter((registry) => !!auths[registry]?.auth) + .map((registry) => ({registry})) + } catch { + return [] + } + }), + + // Save (add or update) credentials for a private registry + setRegistryCredential: privateProcedure + .input( + z.object({ + registry: z.string().min(1), + username: z.string().min(1), + password: z.string().min(1), + }), + ) + .mutation(async ({input}) => { + const config = (await fse.pathExists(DOCKER_CONFIG_PATH)) + ? await fse.readJson(DOCKER_CONFIG_PATH) + : {} + config.auths = config.auths ?? {} + config.auths[input.registry] = { + auth: Buffer.from(`${input.username}:${input.password}`).toString('base64'), + } + await fse.outputJson(DOCKER_CONFIG_PATH, config, {spaces: 2}) + return true + }), + + // Remove credentials for a registry + removeRegistryCredential: privateProcedure + .input(z.object({registry: z.string().min(1)})) + .mutation(async ({input}) => { + try { + const config = await fse.readJson(DOCKER_CONFIG_PATH) + if (config?.auths?.[input.registry]) { + delete config.auths[input.registry] + await fse.outputJson(DOCKER_CONFIG_PATH, config, {spaces: 2}) + } + } catch {} + return true + }), }) export const apps = router({ diff --git a/packages/umbreld/source/modules/utilities/docker-pull.ts b/packages/umbreld/source/modules/utilities/docker-pull.ts index 975e206dc..415e92014 100644 --- a/packages/umbreld/source/modules/utilities/docker-pull.ts +++ b/packages/umbreld/source/modules/utilities/docker-pull.ts @@ -1,17 +1,40 @@ import Dockerode from 'dockerode' +import fse from 'fs-extra' const docker = new Dockerode() const DOWNLOADING_PERCENT = 0.75 const EXTRACTING_PERCENT = 0.25 +async function getAuthConfig(image: string): Promise { + try { + const config = await fse.readJson('/root/.docker/config.json') + const firstSegment = image.split('/')[0] + const registry = firstSegment.includes('.') || firstSegment.includes(':') + ? firstSegment + : 'https://index.docker.io/v1/' + const entry = config?.auths?.[registry] + if (entry?.auth) { + const decoded = Buffer.from(entry.auth, 'base64').toString() + const colonIndex = decoded.indexOf(':') + const username = decoded.slice(0, colonIndex) + const password = decoded.slice(colonIndex + 1) + const serveraddress = registry.startsWith('http') ? registry : `https://${registry}` + return {username, password, serveraddress} + } + } catch {} + return undefined +} + export async function pull( image: string, updateProgress: (progress: number) => void, handleAlreadyDownloaded: () => void, ) { + const authconfig = await getAuthConfig(image) + return new Promise((resolve, reject) => { - docker.pull(image, (error: Error, stream: NodeJS.ReadableStream) => { + docker.pull(image, {authconfig}, (error: Error, stream: NodeJS.ReadableStream) => { if (error) return reject(error) const layerProgress: Record = {}