diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 4ed59561..bdfe0f03 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -901,17 +901,40 @@ app.post( const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; + const mainDb = getDb(); - if (!password) { - return res.status(400).json({ - error: "Password required for import", - code: "PASSWORD_REQUIRED", - }); + const userRecords = await mainDb + .select() + .from(users) + .where(eq(users.id, userId)); + + if (!userRecords || userRecords.length === 0) { + return res.status(404).json({ error: "User not found" }); } - const unlocked = await authManager.authenticateUser(userId, password); - if (!unlocked) { - return res.status(401).json({ error: "Invalid password" }); + const isOidcUser = !!userRecords[0].is_oidc; + + if (!isOidcUser) { + // Local accounts still prove knowledge of the password so their DEK can be derived again. + if (!password) { + return res.status(400).json({ + error: "Password required for import", + code: "PASSWORD_REQUIRED", + }); + } + + const unlocked = await authManager.authenticateUser(userId, password); + if (!unlocked) { + return res.status(401).json({ error: "Invalid password" }); + } + } else if (!DataCrypto.getUserDataKey(userId)) { + // OIDC users skip the password prompt; make sure their DEK is unlocked via the OIDC session. + const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + if (!oidcUnlocked) { + return res.status(403).json({ + error: "Failed to unlock user data with SSO credentials", + }); + } } apiLogger.info("Importing SQLite data", { @@ -922,7 +945,14 @@ app.post( mimetype: req.file.mimetype, }); - const userDataKey = DataCrypto.getUserDataKey(userId); + let userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey && isOidcUser) { + // authenticateOIDCUser lazily provisions the session key; retry the fetch when it succeeds. + const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + if (oidcUnlocked) { + userDataKey = DataCrypto.getUserDataKey(userId); + } + } if (!userDataKey) { throw new Error("User data not unlocked"); } @@ -976,7 +1006,6 @@ app.post( }; try { - const mainDb = getDb(); try { const importedHosts = importDb diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index 0d829895..6d0ff0f8 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -45,6 +45,8 @@ import { makeUserAdmin, removeAdminStatus, deleteUser, + getUserInfo, + getCookie, isElectron, } from "@/ui/main-axios.ts"; @@ -94,6 +96,14 @@ export function AdminSettings({ null, ); + const [securityInitialized, setSecurityInitialized] = React.useState(true); + const [currentUser, setCurrentUser] = React.useState<{ + id: string; + username: string; + is_admin: boolean; + is_oidc: boolean; + } | null>(null); + const [exportLoading, setExportLoading] = React.useState(false); const [importLoading, setImportLoading] = React.useState(false); const [importFile, setImportFile] = React.useState(null); @@ -101,6 +111,11 @@ export function AdminSettings({ const [showPasswordInput, setShowPasswordInput] = React.useState(false); const [importPassword, setImportPassword] = React.useState(""); + const requiresImportPassword = React.useMemo( + () => !currentUser?.is_oidc, + [currentUser?.is_oidc], + ); + React.useEffect(() => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) @@ -119,6 +134,23 @@ export function AdminSettings({ toast.error(t("admin.failedToFetchOidcConfig")); } }); + // Capture the current session so we know whether to ask for a password later. + getUserInfo() + .then((info) => { + if (info) { + setCurrentUser({ + id: info.userId, + username: info.username, + is_admin: info.is_admin, + is_oidc: info.is_oidc, + }); + } + }) + .catch((err) => { + if (!err?.message?.includes("No server configured")) { + console.warn("Failed to fetch current user info", err); + } + }); fetchUsers(); }, []); @@ -372,7 +404,7 @@ export function AdminSettings({ return; } - if (!importPassword.trim()) { + if (requiresImportPassword && !importPassword.trim()) { toast.error(t("admin.passwordRequired")); return; } @@ -395,7 +427,10 @@ export function AdminSettings({ const formData = new FormData(); formData.append("file", importFile); - formData.append("password", importPassword); + if (requiresImportPassword) { + // Preserve the existing password flow for non-OIDC accounts. + formData.append("password", importPassword); + } const response = await fetch(apiUrl, { method: "POST", @@ -1016,7 +1051,8 @@ export function AdminSettings({ - {importFile && ( + {/* Only render the password field when a local account is performing the import. */} + {importFile && requiresImportPassword && (