From e36b5ee0adcf83d5e3e760947b3fe2da2a58cb05 Mon Sep 17 00:00:00 2001 From: chark1es Date: Sat, 8 Mar 2025 21:53:02 -0800 Subject: [PATCH] added the ability to change your password --- astro.config.mjs | 22 + bun.lock | 5 +- package.json | 1 + .../dashboard/SettingsSection.astro | 287 +++++---- .../AccountSecuritySettings.tsx | 36 +- .../PasswordChangeSettings.tsx | 567 ++++++++++++++++++ src/env.ts | 103 ++++ src/pages/api/README-password-change.md | 149 +++++ src/pages/api/change-password.ts | 189 ++++++ src/pages/api/check-env.ts | 44 ++ 10 files changed, 1294 insertions(+), 109 deletions(-) create mode 100644 src/components/dashboard/SettingsSection/PasswordChangeSettings.tsx create mode 100644 src/env.ts create mode 100644 src/pages/api/README-password-change.md create mode 100644 src/pages/api/change-password.ts create mode 100644 src/pages/api/check-env.ts diff --git a/astro.config.mjs b/astro.config.mjs index 8f74173..b08b435 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -15,9 +15,31 @@ import icon from "astro-icon"; // https://astro.build/config export default defineConfig({ + output: "server", integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()], adapter: node({ mode: "standalone", }), + + // Define environment variables that should be available to client components + vite: { + define: { + "import.meta.env.PUBLIC_LOGTO_APP_ID": JSON.stringify( + process.env.PUBLIC_LOGTO_APP_ID, + ), + "import.meta.env.PUBLIC_LOGTO_APP_SECRET": JSON.stringify( + process.env.PUBLIC_LOGTO_APP_SECRET, + ), + "import.meta.env.PUBLIC_LOGTO_ENDPOINT": JSON.stringify( + process.env.PUBLIC_LOGTO_ENDPOINT, + ), + "import.meta.env.PUBLIC_LOGTO_TOKEN_ENDPOINT": JSON.stringify( + process.env.PUBLIC_LOGTO_TOKEN_ENDPOINT, + ), + "import.meta.env.PUBLIC_LOGTO_API_ENDPOINT": JSON.stringify( + process.env.PUBLIC_LOGTO_API_ENDPOINT, + ), + }, + }, }); diff --git a/bun.lock b/bun.lock index 7b10398..d9b694c 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "astro": "5.1.1", "astro-expressive-code": "^0.40.2", "astro-icon": "^1.1.5", + "axios": "^1.8.2", "chart.js": "^4.4.7", "dexie": "^4.0.11", "framer-motion": "^12.4.4", @@ -437,7 +438,7 @@ "autoprefixer": ["autoprefixer@10.4.20", "", { "dependencies": { "browserslist": "^4.23.3", "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g=="], - "axios": ["axios@1.7.9", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw=="], + "axios": ["axios@1.8.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -1427,6 +1428,8 @@ "@expressive-code/plugin-shiki/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], + "@iconify/tools/axios": ["axios@1.7.9", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], diff --git a/package.json b/package.json index 22baf45..cd61df5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "astro": "5.1.1", "astro-expressive-code": "^0.40.2", "astro-icon": "^1.1.5", + "axios": "^1.8.2", "chart.js": "^4.4.7", "dexie": "^4.0.11", "framer-motion": "^12.4.4", diff --git a/src/components/dashboard/SettingsSection.astro b/src/components/dashboard/SettingsSection.astro index 4eb9d97..a6770ec 100644 --- a/src/components/dashboard/SettingsSection.astro +++ b/src/components/dashboard/SettingsSection.astro @@ -5,120 +5,201 @@ import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings"; import NotificationSettings from "./SettingsSection/NotificationSettings"; import DisplaySettings from "./SettingsSection/DisplaySettings"; import ResumeSettings from "./SettingsSection/ResumeSettings"; + +// Import environment variables +const logtoAppId = import.meta.env.LOGTO_APP_ID; +const logtoAppSecret = import.meta.env.LOGTO_APP_SECRET; +const logtoEndpoint = import.meta.env.LOGTO_ENDPOINT; +const logtoTokenEndpoint = import.meta.env.LOGTO_TOKEN_ENDPOINT; +const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT; + +// Log environment variables for debugging +console.log("Environment variables in Astro file:"); +console.log("LOGTO_APP_ID:", logtoAppId); +console.log("LOGTO_APP_SECRET:", logtoAppSecret); +console.log("LOGTO_ENDPOINT:", logtoEndpoint); +console.log("LOGTO_TOKEN_ENDPOINT:", logtoTokenEndpoint); +console.log("LOGTO_API_ENDPOINT:", logtoApiEndpoint); + +// Define fallback values if environment variables are not set +const safeLogtoAppId = logtoAppId || "missing_app_id"; +const safeLogtoAppSecret = logtoAppSecret || "missing_app_secret"; +const safeLogtoEndpoint = logtoEndpoint || "https://auth.ieeeucsd.org"; +const safeLogtoTokenEndpoint = + logtoTokenEndpoint || "https://auth.ieeeucsd.org/oidc/token"; +const safeLogtoApiEndpoint = logtoApiEndpoint || "https://auth.ieeeucsd.org"; ---
-
-

Settings

-

Manage your account settings and preferences

-
+
+

Settings

+

Manage your account settings and preferences

+
- + + { + import.meta.env.DEV && ( +
+

+ Debug Environment Variables +

+

+ This section is only visible in development mode +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableValueStatus
LOGTO_APP_ID{logtoAppId ? "********" : "Not set"}{logtoAppId ? "✅" : "❌"}
LOGTO_APP_SECRET{logtoAppSecret ? "********" : "Not set"}{logtoAppSecret ? "✅" : "❌"}
LOGTO_ENDPOINT{logtoEndpoint || "Not set"}{logtoEndpoint ? "✅" : "❌"}
LOGTO_TOKEN_ENDPOINT{logtoTokenEndpoint || "Not set"}{logtoTokenEndpoint ? "✅" : "❌"}
LOGTO_API_ENDPOINT{logtoApiEndpoint || "Not set"}{logtoApiEndpoint ? "✅" : "❌"}
+
+
+ ) + } + + +
+
+

+
+ +
+ Profile Information +

+

+ Update your personal information and profile details +

+
+ +
+
+ + +
+
+

+
+ +
+ Resume Management +

+

+ Upload and manage your resume for recruiters and career opportunities +

+
+ +
+
+ + +
+
+

+
+ +
+ Account Security +

+

+ Manage your account security settings and authentication options +

+
+ +
+
+ + +
+
-
-

-
- -
- Profile Information -

-

- Update your personal information and profile details -

-
- -
+
+

Coming Soon

+

+ Notification settings will be available in a future update +

+
- -
-
-

-
- -
- Resume Management -

-

- Upload and manage your resume for recruiters and career - opportunities -

-
- +
+

+
+
+ Notification Preferences +

+

+ Customize how and when you receive notifications +

+
+
+
- -
-
-

-
- -
- Account Security -

-

- Manage your account security settings and authentication options -

-
- -
-
- - -
- -
-
-

Coming Soon

-

- Notification settings will be available in a future update -

-
-
- -
-

-
- -
- Notification Preferences -

-

- Customize how and when you receive notifications -

-
- -
-
- - -
-
-

-
- -
- Display Settings -

-

- Customize your dashboard appearance and display preferences -

-
- + +
+
+

+
+
+ Display Settings +

+

+ Customize your dashboard appearance and display preferences +

+
+
+
diff --git a/src/components/dashboard/SettingsSection/AccountSecuritySettings.tsx b/src/components/dashboard/SettingsSection/AccountSecuritySettings.tsx index 511ba62..f0917da 100644 --- a/src/components/dashboard/SettingsSection/AccountSecuritySettings.tsx +++ b/src/components/dashboard/SettingsSection/AccountSecuritySettings.tsx @@ -2,8 +2,23 @@ import { useState, useEffect } from 'react'; import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { SendLog } from '../../../scripts/pocketbase/SendLog'; import { toast } from 'react-hot-toast'; +import PasswordChangeSettings from './PasswordChangeSettings'; -export default function AccountSecuritySettings() { +interface AccountSecuritySettingsProps { + logtoAppId: string; + logtoAppSecret: string; + logtoEndpoint: string; + logtoTokenEndpoint: string; + logtoApiEndpoint: string; +} + +export default function AccountSecuritySettings({ + logtoAppId, + logtoAppSecret, + logtoEndpoint, + logtoTokenEndpoint, + logtoApiEndpoint +}: AccountSecuritySettingsProps) { const auth = Authentication.getInstance(); const logger = SendLog.getInstance(); const [isAuthenticated, setIsAuthenticated] = useState(false); @@ -126,6 +141,21 @@ export default function AccountSecuritySettings() {
+ {/* Password Change Section */} +
+

Change Password

+

+ Update your account password. For security reasons, you'll need to provide your current password. +

+ +
+ {/* Authentication Options */}

Authentication Options

@@ -133,10 +163,6 @@ export default function AccountSecuritySettings() { IEEE UCSD uses Single Sign-On (SSO) for authentication. Password management is handled through your IEEEUCSD account.

- -

- To change your password, please use the "Forgot Password" option on the login page. -

{/* Account Actions */} diff --git a/src/components/dashboard/SettingsSection/PasswordChangeSettings.tsx b/src/components/dashboard/SettingsSection/PasswordChangeSettings.tsx new file mode 100644 index 0000000..04c40c5 --- /dev/null +++ b/src/components/dashboard/SettingsSection/PasswordChangeSettings.tsx @@ -0,0 +1,567 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'react-hot-toast'; +import { Authentication } from '../../../scripts/pocketbase/Authentication'; +import { SendLog } from '../../../scripts/pocketbase/SendLog'; + +interface PasswordChangeSettingsProps { + logtoAppId?: string; + logtoAppSecret?: string; + logtoEndpoint?: string; + logtoTokenEndpoint?: string; + logtoApiEndpoint?: string; +} + +export default function PasswordChangeSettings({ + logtoAppId: propLogtoAppId, + logtoAppSecret: propLogtoAppSecret, + logtoEndpoint: propLogtoEndpoint, + logtoTokenEndpoint: propLogtoTokenEndpoint, + logtoApiEndpoint: propLogtoApiEndpoint +}: PasswordChangeSettingsProps) { + const auth = Authentication.getInstance(); + const logger = SendLog.getInstance(); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingEnv, setIsCheckingEnv] = useState(false); + const [useFormSubmission, setUseFormSubmission] = useState(false); // Default to using JSON + const [formData, setFormData] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + const [logtoUserId, setLogtoUserId] = useState(''); + const [debugInfo, setDebugInfo] = useState(null); + + // Access environment variables directly + const envLogtoAppId = import.meta.env.PUBLIC_LOGTO_APP_ID; + const envLogtoAppSecret = import.meta.env.PUBLIC_LOGTO_APP_SECRET; + const envLogtoEndpoint = import.meta.env.PUBLIC_LOGTO_ENDPOINT; + const envLogtoTokenEndpoint = import.meta.env.PUBLIC_LOGTO_TOKEN_ENDPOINT; + const envLogtoApiEndpoint = import.meta.env.PUBLIC_LOGTO_API_ENDPOINT; + + // Use environment variables or props (fallback) + const logtoAppId = envLogtoAppId || propLogtoAppId; + const logtoAppSecret = envLogtoAppSecret || propLogtoAppSecret; + const logtoEndpoint = envLogtoEndpoint || propLogtoEndpoint; + const logtoTokenEndpoint = envLogtoTokenEndpoint || propLogtoTokenEndpoint; + const logtoApiEndpoint = envLogtoApiEndpoint || propLogtoApiEndpoint; + + // Log props and environment variables for debugging + useEffect(() => { + console.log("PasswordChangeSettings props and env vars:"); + console.log("Props - logtoAppId:", propLogtoAppId); + console.log("Props - logtoAppSecret:", propLogtoAppSecret); + console.log("Props - logtoEndpoint:", propLogtoEndpoint); + console.log("Props - logtoTokenEndpoint:", propLogtoTokenEndpoint); + console.log("Props - logtoApiEndpoint:", propLogtoApiEndpoint); + console.log("Env - PUBLIC_LOGTO_APP_ID:", envLogtoAppId); + console.log("Env - PUBLIC_LOGTO_APP_SECRET:", envLogtoAppSecret); + console.log("Env - PUBLIC_LOGTO_ENDPOINT:", envLogtoEndpoint); + console.log("Env - PUBLIC_LOGTO_TOKEN_ENDPOINT:", envLogtoTokenEndpoint); + console.log("Env - PUBLIC_LOGTO_API_ENDPOINT:", envLogtoApiEndpoint); + console.log("Using - logtoAppId:", logtoAppId); + console.log("Using - logtoAppSecret:", logtoAppSecret); + console.log("Using - logtoEndpoint:", logtoEndpoint); + console.log("Using - logtoTokenEndpoint:", logtoTokenEndpoint); + console.log("Using - logtoApiEndpoint:", logtoApiEndpoint); + }, [ + propLogtoAppId, propLogtoAppSecret, propLogtoEndpoint, propLogtoTokenEndpoint, propLogtoApiEndpoint, + envLogtoAppId, envLogtoAppSecret, envLogtoEndpoint, envLogtoTokenEndpoint, envLogtoApiEndpoint, + logtoAppId, logtoAppSecret, logtoEndpoint, logtoTokenEndpoint, logtoApiEndpoint + ]); + + // Get the user's Logto ID on component mount + useEffect(() => { + const fetchLogtoUserId = async () => { + try { + const user = auth.getCurrentUser(); + if (!user) { + console.error("User not authenticated"); + toast.error("You must be logged in to change your password"); + return; + } + + console.log("Current user:", user); + const pb = auth.getPocketBase(); + + try { + const externalAuthRecord = await pb.collection('_externalAuths').getFirstListItem(`recordRef="${user.id}" && provider="oidc"`); + console.log("Found external auth record:", externalAuthRecord); + + const userId = externalAuthRecord.providerId; + if (userId) { + setLogtoUserId(userId); + console.log("Set Logto user ID:", userId); + } else { + console.error("No providerId found in external auth record"); + toast.error("Could not determine your user ID. Please try again later or contact support."); + } + } catch (error) { + console.error("Error fetching external auth record:", error); + toast.error("Error retrieving your user information. Please try again later."); + + // Try to get more information about the error + if (error instanceof Error) { + console.error("Error details:", error.message); + if ('data' in error) { + console.error("Error data:", (error as any).data); + } + } + } + } catch (error) { + console.error("Error fetching Logto user ID:", error); + toast.error("Error retrieving your user information. Please try again later."); + } + }; + + fetchLogtoUserId(); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const validateForm = () => { + if (!formData.currentPassword) { + toast.error('Current password is required'); + return false; + } + if (!formData.newPassword) { + toast.error('New password is required'); + return false; + } + if (formData.newPassword.length < 8) { + toast.error('New password must be at least 8 characters long'); + return false; + } + if (formData.newPassword !== formData.confirmPassword) { + toast.error('New passwords do not match'); + return false; + } + if (!logtoUserId) { + toast.error('Could not determine your user ID. Please try again later.'); + return false; + } + return true; + }; + + const checkEnvironmentVariables = async () => { + setIsCheckingEnv(true); + try { + const response = await fetch('/api/check-env'); + const data = await response.json(); + console.log("Environment variables status:", data); + + // Check if all required environment variables are set + const { envStatus } = data; + const missingVars = Object.entries(envStatus) + .filter(([_, isSet]) => !isSet) + .map(([name]) => name); + + if (missingVars.length > 0) { + toast.error(`Missing environment variables: ${missingVars.join(', ')}`); + } else { + toast.success('All environment variables are set'); + } + } catch (error) { + console.error("Error checking environment variables:", error); + toast.error('Failed to check environment variables'); + } finally { + setIsCheckingEnv(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsLoading(true); + setDebugInfo(null); + + try { + if (useFormSubmission) { + // Use a traditional form submission approach + const formElement = document.createElement('form'); + formElement.method = 'POST'; + formElement.action = '/api/change-password'; + formElement.enctype = 'application/x-www-form-urlencoded'; + + // Add the userId field + const userIdField = document.createElement('input'); + userIdField.type = 'hidden'; + userIdField.name = 'userId'; + userIdField.value = logtoUserId; + formElement.appendChild(userIdField); + + // Add the newPassword field + const newPasswordField = document.createElement('input'); + newPasswordField.type = 'hidden'; + newPasswordField.name = 'newPassword'; + newPasswordField.value = formData.newPassword; + formElement.appendChild(newPasswordField); + + // If not using hardcoded endpoint, add the Logto credentials + + const logtoAppIdField = document.createElement('input'); + logtoAppIdField.type = 'hidden'; + logtoAppIdField.name = 'logtoAppId'; + logtoAppIdField.value = logtoAppId; + formElement.appendChild(logtoAppIdField); + + const logtoAppSecretField = document.createElement('input'); + logtoAppSecretField.type = 'hidden'; + logtoAppSecretField.name = 'logtoAppSecret'; + logtoAppSecretField.value = logtoAppSecret; + formElement.appendChild(logtoAppSecretField); + + const logtoTokenEndpointField = document.createElement('input'); + logtoTokenEndpointField.type = 'hidden'; + logtoTokenEndpointField.name = 'logtoTokenEndpoint'; + logtoTokenEndpointField.value = logtoTokenEndpoint; + formElement.appendChild(logtoTokenEndpointField); + + const logtoApiEndpointField = document.createElement('input'); + logtoApiEndpointField.type = 'hidden'; + logtoApiEndpointField.name = 'logtoApiEndpoint'; + logtoApiEndpointField.value = logtoApiEndpoint; + formElement.appendChild(logtoApiEndpointField); + + + // Create an iframe to handle the form submission + const iframe = document.createElement('iframe'); + iframe.name = 'password-change-frame'; + iframe.style.display = 'none'; + document.body.appendChild(iframe); + + // Set up the iframe load event to handle the response + iframe.onload = async () => { + try { + // Try to get the response from the iframe + const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document; + if (iframeDocument) { + const responseText = iframeDocument.body.innerText; + console.log("Response from iframe:", responseText); + + if (responseText) { + try { + const result = JSON.parse(responseText); + if (result.success) { + // Log the password change + await logger.send('update', 'password', 'User changed their password'); + + toast.success('Password changed successfully'); + + // Clear form + setFormData({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + } else { + toast.error(result.message || 'Failed to change password'); + } + } catch (error) { + console.error("Error parsing response:", error); + toast.error('Failed to parse response from server'); + } + } else { + // If no response text, assume success + // Log the password change + await logger.send('update', 'password', 'User changed their password'); + + toast.success('Password changed successfully'); + + // Clear form + setFormData({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + } + } else { + console.error("Could not access iframe document"); + toast.error('Could not access response from server'); + } + } catch (error) { + console.error("Error handling iframe response:", error); + toast.error('Error handling response from server'); + } finally { + // Clean up + document.body.removeChild(iframe); + setIsLoading(false); + } + }; + + // Set the target to the iframe + formElement.target = 'password-change-frame'; + + // Append the form to the document, submit it, and then remove it + document.body.appendChild(formElement); + formElement.submit(); + document.body.removeChild(formElement); + + // Note: setIsLoading(false) is called in the iframe.onload handler + } else { + // Use the fetch API with JSON + const endpoint = '/api/change-password'; + console.log(`Calling server-side API endpoint: ${endpoint}`); + + // Ensure we have the Logto user ID + if (!logtoUserId) { + console.error("Logto user ID is missing"); + throw new Error("User ID is missing. Please try again or contact support."); + } + + // Log the values we're about to use + console.log("Values being used for API call:"); + console.log("- logtoUserId:", logtoUserId); + console.log("- newPassword:", formData.newPassword ? "[PRESENT]" : "[MISSING]"); + console.log("- logtoAppId:", logtoAppId); + console.log("- logtoAppSecret:", logtoAppSecret ? "[PRESENT]" : "[MISSING]"); + console.log("- logtoTokenEndpoint:", logtoTokenEndpoint); + console.log("- logtoApiEndpoint:", logtoApiEndpoint); + + // Prepare request data with explicit values (not relying on variable references that might be undefined) + const requestData = { + userId: logtoUserId, + newPassword: formData.newPassword, + logtoAppId: logtoAppId || "", + logtoAppSecret: logtoAppSecret || "", + logtoTokenEndpoint: logtoTokenEndpoint || `${logtoEndpoint}/oidc/token`, + logtoApiEndpoint: logtoApiEndpoint || logtoEndpoint + }; + + console.log("Request data:", { + ...requestData, + newPassword: "[REDACTED]", + logtoAppSecret: "[REDACTED]" + }); + + // Validate request data before sending + if (!requestData.userId) { + throw new Error("Missing userId. Please try again or contact support."); + } + + if (!requestData.newPassword) { + throw new Error("Missing newPassword. Please enter a new password."); + } + + if (!requestData.logtoAppId) { + throw new Error("Missing logtoAppId configuration. Please contact support."); + } + if (!requestData.logtoAppSecret) { + throw new Error("Missing logtoAppSecret configuration. Please contact support."); + } + if (!requestData.logtoTokenEndpoint) { + throw new Error("Missing logtoTokenEndpoint configuration. Please contact support."); + } + if (!requestData.logtoApiEndpoint) { + throw new Error("Missing logtoApiEndpoint configuration. Please contact support."); + } + + + // Stringify the request data to ensure it's valid JSON + const requestBody = JSON.stringify(requestData); + console.log("Request body (stringified):", requestBody); + + // Create a debug object to display in the UI + const debugObj = { + endpoint, + requestData, + requestBody, + logtoUserId, + hasNewPassword: !!formData.newPassword, + hasLogtoAppId: !!logtoAppId, + hasLogtoAppSecret: !!logtoAppSecret, + hasLogtoTokenEndpoint: !!logtoTokenEndpoint, + hasLogtoApiEndpoint: !!logtoApiEndpoint + }; + setDebugInfo(debugObj); + + // Call our server-side API endpoint to change the password + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: requestBody + }); + + console.log("Response status:", response.status); + + // Process the response + let result: any; + try { + const responseText = await response.text(); + console.log("Raw response:", responseText); + + if (responseText) { + result = JSON.parse(responseText); + } else { + result = { success: false, message: 'Empty response from server' }; + } + + console.log("API response:", result); + + // Add response to debug info + setDebugInfo((prev: any) => ({ + ...prev, + responseStatus: response.status, + responseText, + parsedResponse: result + })); + } catch (error) { + console.error("Error parsing API response:", error); + setDebugInfo((prev: any) => ({ + ...prev, + responseError: error instanceof Error ? error.message : String(error) + })); + throw new Error(`Invalid response from server: ${error instanceof Error ? error.message : String(error)}`); + } + + // Check if the request was successful + if (response.ok && result.success) { + // Log the password change + await logger.send('update', 'password', 'User changed their password'); + + toast.success('Password changed successfully'); + + // Clear form + setFormData({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + } else { + throw new Error(result.message || `Failed to change password: ${response.status}`); + } + + setIsLoading(false); + } + } catch (error) { + console.error('Error changing password:', error); + toast.error(error instanceof Error ? error.message : 'Failed to change password'); + setIsLoading(false); + } + }; + + return ( +
+ {process.env.NODE_ENV === 'development' && ( +
+

Debug Tools (Development Only)

+
+ + + +
+
+

Using fixed LogTo implementation with {useFormSubmission ? 'form submission' : 'JSON'}

+

Logto User ID: {logtoUserId || 'Not found'}

+
+ + {debugInfo && ( +
+

Debug Info:

+
+
{JSON.stringify(debugInfo, null, 2)}
+
+
+ )} +
+ )} + +
+
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..86d6cc5 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +/** + * Define a schema for environment variables with Zod + * This provides type safety and validation for environment variables + */ +const envSchema = z.object({ + // LogTo Configuration + LOGTO_APP_ID: z.string().min(1, "LOGTO_APP_ID is required"), + LOGTO_APP_SECRET: z.string().min(1, "LOGTO_APP_SECRET is required"), + LOGTO_ENDPOINT: z.string().url("LOGTO_ENDPOINT must be a valid URL"), + LOGTO_TOKEN_ENDPOINT: z + .string() + .url("LOGTO_TOKEN_ENDPOINT must be a valid URL"), + LOGTO_API_ENDPOINT: z.string().url("LOGTO_API_ENDPOINT must be a valid URL"), + LOGTO_USERINFO_ENDPOINT: z + .string() + .url("LOGTO_USERINFO_ENDPOINT must be a valid URL"), + // API Base URL (optional) + API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL").optional(), +}); + +/** + * Parse and validate environment variables + * This will throw an error if any required variables are missing or invalid + */ +function getEnvVariables() { + // In development, use import.meta.env (Vite/Astro) + if (import.meta.env) { + const envVars = { + LOGTO_APP_ID: import.meta.env.LOGTO_APP_ID, + LOGTO_APP_SECRET: import.meta.env.LOGTO_APP_SECRET, + LOGTO_ENDPOINT: import.meta.env.LOGTO_ENDPOINT, + LOGTO_TOKEN_ENDPOINT: import.meta.env.LOGTO_TOKEN_ENDPOINT, + LOGTO_API_ENDPOINT: import.meta.env.LOGTO_API_ENDPOINT, + LOGTO_USERINFO_ENDPOINT: import.meta.env.LOGTO_USERINFO_ENDPOINT, + API_BASE_URL: import.meta.env.API_BASE_URL, + }; + + try { + return envSchema.parse(envVars); + } catch (error) { + console.error("Environment variable validation failed:", error); + + // Log which variables are missing or invalid + if (error instanceof z.ZodError) { + error.errors.forEach((err) => { + console.error(`- ${err.path.join(".")}: ${err.message}`); + }); + } + + // Return default values for development with warnings + console.warn( + "Using fallback values for development. DO NOT USE IN PRODUCTION!", + ); + return { + LOGTO_APP_ID: import.meta.env.LOGTO_APP_ID || "development_app_id", + LOGTO_APP_SECRET: + import.meta.env.LOGTO_APP_SECRET || "development_app_secret", + LOGTO_ENDPOINT: + import.meta.env.LOGTO_ENDPOINT || "https://auth.ieeeucsd.org", + LOGTO_TOKEN_ENDPOINT: + import.meta.env.LOGTO_TOKEN_ENDPOINT || + "https://auth.ieeeucsd.org/oidc/token", + LOGTO_API_ENDPOINT: + import.meta.env.LOGTO_API_ENDPOINT || "https://auth.ieeeucsd.org", + LOGTO_USERINFO_ENDPOINT: + import.meta.env.LOGTO_USERINFO_ENDPOINT || + "https://auth.ieeeucsd.org/oidc/me", + API_BASE_URL: import.meta.env.API_BASE_URL || "http://localhost:4321", + }; + } + } + + // In Node.js environment (server-side) + if (typeof process !== "undefined" && process.env) { + const envVars = { + LOGTO_APP_ID: process.env.LOGTO_APP_ID || "", + LOGTO_APP_SECRET: process.env.LOGTO_APP_SECRET || "", + LOGTO_ENDPOINT: process.env.LOGTO_ENDPOINT || "", + LOGTO_TOKEN_ENDPOINT: process.env.LOGTO_TOKEN_ENDPOINT || "", + LOGTO_API_ENDPOINT: process.env.LOGTO_API_ENDPOINT || "", + LOGTO_USERINFO_ENDPOINT: process.env.LOGTO_USERINFO_ENDPOINT || "", + API_BASE_URL: process.env.API_BASE_URL, + }; + + try { + return envSchema.parse(envVars); + } catch (error) { + console.error("Environment variable validation failed:", error); + throw new Error("Missing or invalid environment variables"); + } + } + + // Fallback for other environments + throw new Error("Unable to load environment variables"); +} + +// Export the validated environment variables +export const env = getEnvVariables(); + +// Type definition for the environment variables +export type Env = z.infer; diff --git a/src/pages/api/README-password-change.md b/src/pages/api/README-password-change.md new file mode 100644 index 0000000..6cafdd7 --- /dev/null +++ b/src/pages/api/README-password-change.md @@ -0,0 +1,149 @@ +# LogTo Password Change Implementation + +This document explains how the password change functionality works with LogTo authentication. + +## Overview + +The password change functionality uses LogTo's Management API to update user passwords. The implementation follows the Machine-to-Machine (M2M) authentication flow as described in the LogTo documentation. + +## Key Files + +1. **`/src/pages/api/change-password.ts`**: The server-side API endpoint that handles password change requests +2. **`/src/components/dashboard/SettingsSection/PasswordChangeSettings.tsx`**: The React component that provides the password change UI +3. **`/src/components/dashboard/SettingsSection/AccountSecuritySettings.tsx`**: The parent component that includes the password change functionality + +## How It Works + +### Authentication Flow + +1. The client sends a password change request with the user ID and new password +2. The server obtains an access token using the client credentials flow +3. The server uses the access token to make an authenticated request to the LogTo Management API +4. The LogTo API updates the user's password + +### Implementation Details + +#### 1. Client Credentials Flow + +The implementation tries multiple approaches to obtain an access token using the OAuth 2.0 client credentials flow: + +```javascript +// Attempt 1: Without resource parameter +let tokenResponse = await fetch(logtoTokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: logtoAppId, + client_secret: logtoAppSecret, + scope: "all", + }).toString(), +}); + +// Attempt 2: With Basic Auth +tokenResponse = await fetch(logtoTokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${Buffer.from(`${logtoAppId}:${logtoAppSecret}`).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: "client_credentials", + scope: "all", + }).toString(), +}); + +// Attempt 3: With organization_id +tokenResponse = await fetch(logtoTokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: logtoAppId, + client_secret: logtoAppSecret, + organization_id: "default", + scope: "all", + }).toString(), +}); + +// Attempt 4: With audience parameter +tokenResponse = await fetch(logtoTokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: logtoAppId, + client_secret: logtoAppSecret, + audience: "https://auth.ieeeucsd.org/api", + scope: "all", + }).toString(), +}); +``` + +Key points: + +- The `grant_type` must be `client_credentials` +- Multiple approaches are tried to handle different LogTo configurations +- The `scope` parameter is set to `all` to request all available permissions + +#### 2. Password Update API + +After obtaining an access token, the implementation calls the LogTo Management API to update the password: + +```javascript +const passwordEndpoint = `${logtoApiEndpoint}/api/users/${userId}/password`; + +const changePasswordResponse = await fetch(passwordEndpoint, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + password: newPassword, + }), +}); +``` + +Key points: + +- The endpoint is `/api/users/{userId}/password` +- The HTTP method is `PATCH` +- The request body contains only the `password` field +- The `Authorization` header must include the access token + +## Troubleshooting + +### Common Issues + +1. **Authentication Errors (401)** + + - Check that the client ID and secret are correct + - Verify that the M2M application has the necessary permissions + +2. **Permission Errors (403)** + + - Ensure the M2M application has been assigned the appropriate role + - Check that the role has the necessary permissions for user management + +3. **Resource Parameter Issues** + + - The implementation tries multiple approaches to handle resource parameter issues + - Different LogTo configurations may require different parameters (resource, audience, etc.) + +4. **User ID Issues** + - Ensure the user ID is correctly retrieved from the authentication system + - The user ID should match the LogTo user ID, not the local user ID + +## References + +- [LogTo M2M Quick Start](https://docs.logto.io/quick-starts/m2m) +- [LogTo API Reference](https://openapi.logto.io/) +- [LogTo Update User Password API](https://openapi.logto.io/operation/operation-updateuserpassword) +- [LogTo Authentication](https://openapi.logto.io/authentication) diff --git a/src/pages/api/change-password.ts b/src/pages/api/change-password.ts new file mode 100644 index 0000000..efce815 --- /dev/null +++ b/src/pages/api/change-password.ts @@ -0,0 +1,189 @@ +import type { APIRoute } from "astro"; + +// Mark this endpoint as server-rendered, not static +export const prerender = false; + +// Helper function to get Logto access token +async function getLogtoAccessToken( + logtoTokenEndpoint: string, + clientId: string, + clientSecret: string, + scope: string = "all", +): Promise { + try { + console.log("Attempting to get access token from Logto"); + + // Create Basic auth string + const authString = Buffer.from(`${clientId}:${clientSecret}`).toString( + "base64", + ); + + const params = new URLSearchParams({ + grant_type: "client_credentials", + resource: "https://default.logto.app/api", + scope: scope, + }); + + const response = await fetch(logtoTokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${authString}`, + }, + body: params, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Token request error:", { + status: response.status, + statusText: response.statusText, + body: errorText, + }); + return null; + } + + const data = await response.json(); + return data.access_token || null; + } catch (error) { + console.error("Network error fetching token:", error); + return null; + } +} + +export const POST: APIRoute = async ({ request }) => { + try { + console.log("Received change password request"); + + // Parse request body + const contentType = request.headers.get("content-type"); + const rawBody = await request.text(); + + let data; + if (contentType?.includes("application/json")) { + data = JSON.parse(rawBody); + } else if (contentType?.includes("application/x-www-form-urlencoded")) { + const formData = new URLSearchParams(rawBody); + data = Object.fromEntries(formData.entries()); + } else { + return new Response( + JSON.stringify({ + success: false, + message: "Unsupported content type. Please use JSON or form data.", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Extract required parameters + const { + userId, + newPassword, + logtoAppId, + logtoAppSecret, + logtoTokenEndpoint, + logtoApiEndpoint, + } = data; + + // Validate required parameters + const missingParams = []; + if (!userId) missingParams.push("userId"); + if (!newPassword) missingParams.push("newPassword"); + if (!logtoAppId) missingParams.push("logtoAppId"); + if (!logtoAppSecret) missingParams.push("logtoAppSecret"); + if (!logtoTokenEndpoint) missingParams.push("logtoTokenEndpoint"); + if (!logtoApiEndpoint) missingParams.push("logtoApiEndpoint"); + + if (missingParams.length > 0) { + return new Response( + JSON.stringify({ + success: false, + message: `Missing required parameters: ${missingParams.join(", ")}`, + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Get access token + const accessToken = await getLogtoAccessToken( + logtoTokenEndpoint, + logtoAppId, + logtoAppSecret, + ); + + if (!accessToken) { + return new Response( + JSON.stringify({ + success: false, + message: "Failed to obtain access token from Logto", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Change password using Logto Management API + const changePasswordResponse = await fetch( + `${logtoApiEndpoint}/api/users/${userId}/password`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ password: newPassword }), + }, + ); + + if (!changePasswordResponse.ok) { + const errorData = await changePasswordResponse.json().catch(() => ({ + message: "Could not read error response", + })); + + return new Response( + JSON.stringify({ + success: false, + message: + errorData.message || + `Failed to change password: ${changePasswordResponse.status} ${changePasswordResponse.statusText}`, + }), + { + status: changePasswordResponse.status, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response( + JSON.stringify({ + success: true, + message: "Password changed successfully", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } catch (error) { + console.error("Unexpected error in change-password API:", error); + return new Response( + JSON.stringify({ + success: false, + message: + error instanceof Error ? error.message : "An unknown error occurred", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } +}; diff --git a/src/pages/api/check-env.ts b/src/pages/api/check-env.ts new file mode 100644 index 0000000..e758724 --- /dev/null +++ b/src/pages/api/check-env.ts @@ -0,0 +1,44 @@ +import type { APIRoute } from "astro"; + +// Mark this endpoint as server-rendered, not static +export const prerender = false; + +export const GET: APIRoute = async () => { + // Get environment variables + const logtoAppId = import.meta.env.LOGTO_APP_ID; + const logtoAppSecret = import.meta.env.LOGTO_APP_SECRET; + const logtoEndpoint = import.meta.env.LOGTO_ENDPOINT; + const logtoTokenEndpoint = import.meta.env.LOGTO_TOKEN_ENDPOINT; + const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT; + + // Check which environment variables are set + const envStatus = { + LOGTO_APP_ID: !!logtoAppId, + LOGTO_APP_SECRET: !!logtoAppSecret, + LOGTO_ENDPOINT: !!logtoEndpoint, + LOGTO_TOKEN_ENDPOINT: !!logtoTokenEndpoint, + LOGTO_API_ENDPOINT: !!logtoApiEndpoint, + }; + + // Return the status + return new Response( + JSON.stringify({ + message: "Environment variables status", + envStatus, + // Include the actual values for debugging (except secrets) + envValues: { + LOGTO_APP_ID: logtoAppId ? "********" : null, + LOGTO_APP_SECRET: logtoAppSecret ? "********" : null, + LOGTO_ENDPOINT: logtoEndpoint, + LOGTO_TOKEN_ENDPOINT: logtoTokenEndpoint, + LOGTO_API_ENDPOINT: logtoApiEndpoint, + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); +};