send_email_through_keyclock
Differences
This shows you the differences between two versions of the page.
| Both sides previous revisionPrevious revisionNext revision | Previous revision | ||
| send_email_through_keyclock [2026/03/09 07:58] – old revision restored (2026/03/09 07:54) sonali | send_email_through_keyclock [2026/03/09 08:00] (current) – sonali | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| + | < | ||
| + | A system where users can log in using their **email Id /Username + Email OTP** instead of username/ | ||
| **Make Email OTP Java SPI ** | **Make Email OTP Java SPI ** | ||
| Line 168: | Line 170: | ||
| - No required user action available in user details\\ | - No required user action available in user details\\ | ||
| - Set Email & password | - Set Email & password | ||
| - | |||
| - | Sample app backend code | ||
| - | |||
| - | < | ||
| - | const express = require(' | ||
| - | |||
| - | const cors = require(' | ||
| - | |||
| - | const axios = require(' | ||
| - | |||
| - | const https = require(' | ||
| - | |||
| - | const qs = require(' | ||
| - | |||
| - | const app = express(); | ||
| - | |||
| - | app.set(' | ||
| - | |||
| - | app.use(cors({ origin: ' | ||
| - | |||
| - | app.use(express.json()); | ||
| - | |||
| - | app.use(express.urlencoded({ extended: true })); | ||
| - | |||
| - | // Allow self-signed certs (common for dev Keycloak instances) | ||
| - | |||
| - | const httpsAgent = new https.Agent({ rejectUnauthorized: | ||
| - | |||
| - | // In-memory OTP session store (use Redis in production) | ||
| - | |||
| - | const otpSessions = new Map(); | ||
| - | |||
| - | // Clean up expired sessions every 5 minutes (TTL = 10 minutes) | ||
| - | |||
| - | setInterval(() => { | ||
| - | |||
| - | const cutoff = Date.now() - 10 * 60 * 1000; | ||
| - | |||
| - | for (const [id, session] of otpSessions) { | ||
| - | |||
| - | if (session.createdAt <cutoff) otpSessions.delete(id); | ||
| - | |||
| - | } | ||
| - | |||
| - | }, 5 * 60 * 1000); | ||
| - | |||
| - | /** | ||
| - | |||
| - | * Step 1: Initiate Keycloak email OTP flow | ||
| - | |||
| - | * | ||
| - | |||
| - | * 1. Fetch Keycloak login form → extract action URL + cookies | ||
| - | |||
| - | * 2. POST credentials to Keycloak → Keycloak sends OTP email, returns OTP form | ||
| - | |||
| - | * 3. Extract OTP form action URL, store session, return { requiresOTP, | ||
| - | |||
| - | */ | ||
| - | |||
| - | app.post('/ | ||
| - | |||
| - | const { kcUrl, realm, clientId, username } = req.body; | ||
| - | |||
| - | if (!kcUrl || !realm || !clientId || !username) { | ||
| - | |||
| - | return res.status(400).json({ error: 'All fields are required.' | ||
| - | |||
| - | } | ||
| - | |||
| - | const redirectUri = `${req.protocol}:// | ||
| - | |||
| - | const authUrl = `${kcUrl}/ | ||
| - | |||
| - | try { | ||
| - | |||
| - | // 1. GET Keycloak login page | ||
| - | |||
| - | const authRes = await axios.get(authUrl, | ||
| - | |||
| - | params: { | ||
| - | |||
| - | client_id: clientId, | ||
| - | |||
| - | response_type: | ||
| - | |||
| - | scope: ' | ||
| - | |||
| - | redirect_uri: | ||
| - | |||
| - | }, | ||
| - | |||
| - | httpsAgent, | ||
| - | |||
| - | maxRedirects: | ||
| - | |||
| - | validateStatus: | ||
| - | |||
| - | }); | ||
| - | |||
| - | if (authRes.status !== 200) { | ||
| - | |||
| - | // Extract Keycloak' | ||
| - | |||
| - | let kcDetail = ''; | ||
| - | |||
| - | try { | ||
| - | |||
| - | const body = typeof authRes.data === ' | ||
| - | |||
| - | const errMatch = body.match(/ | ||
| - | |||
| - | kcDetail = errMatch ? ' | Keycloak says: ' + errMatch[0].replace(/< | ||
| - | |||
| - | } catch (_) {} | ||
| - | |||
| - | return res.status(400).json({ | ||
| - | |||
| - | error: `Keycloak HTTP ${authRes.status} at ${authUrl}.${kcDetail}`, | ||
| - | |||
| - | }); | ||
| - | |||
| - | } | ||
| - | |||
| - | // Extract form action URL and session cookies | ||
| - | |||
| - | const loginHtml = authRes.data; | ||
| - | |||
| - | const actionMatch = loginHtml.match(/ | ||
| - | |||
| - | if (!actionMatch) { | ||
| - | |||
| - | return res.status(500).json({ error: 'Could not find login form in Keycloak response.' | ||
| - | |||
| - | } | ||
| - | |||
| - | const loginActionUrl = actionMatch[1].replace(/&/ | ||
| - | |||
| - | const loginCookies = parseCookies(authRes.headers[' | ||
| - | |||
| - | // 2. POST credentials to Keycloak | ||
| - | |||
| - | const credRes = await axios.post( | ||
| - | |||
| - | loginActionUrl, | ||
| - | |||
| - | qs.stringify({ username }), | ||
| - | |||
| - | { | ||
| - | |||
| - | headers: { | ||
| - | |||
| - | ' | ||
| - | |||
| - | ' | ||
| - | |||
| - | }, | ||
| - | |||
| - | httpsAgent, | ||
| - | |||
| - | maxRedirects: | ||
| - | |||
| - | validateStatus: | ||
| - | |||
| - | } | ||
| - | |||
| - | ); | ||
| - | |||
| - | // If Keycloak redirected with code → OTP not required, direct login success | ||
| - | |||
| - | if (credRes.status === 302 && credRes.headers.location? | ||
| - | |||
| - | const code = new URL(credRes.headers.location).searchParams.get(' | ||
| - | |||
| - | const tokens = await exchangeCodeForTokens(kcUrl, | ||
| - | |||
| - | return res.json({ success: true, tokens }); | ||
| - | |||
| - | } | ||
| - | |||
| - | // Log what Keycloak actually returned for debugging | ||
| - | |||
| - | console.log(' | ||
| - | |||
| - | console.log(' | ||
| - | |||
| - | // If Keycloak returned an error page (wrong credentials etc.) | ||
| - | |||
| - | if (credRes.status === 200 && credRes.data? | ||
| - | |||
| - | return res.status(401).json({ error: ' | ||
| - | |||
| - | } | ||
| - | |||
| - | // Keycloak may redirect (302) to the OTP form page — follow it | ||
| - | |||
| - | let otpHtml = credRes.data; | ||
| - | |||
| - | let otpCookies = mergeCookies(loginCookies, | ||
| - | |||
| - | if (credRes.status === 302 && credRes.headers.location && !credRes.headers.location.includes(' | ||
| - | |||
| - | const redirectUrl = credRes.headers.location; | ||
| - | |||
| - | const otpPageRes = await axios.get(redirectUrl, | ||
| - | |||
| - | headers: { ' | ||
| - | |||
| - | httpsAgent, | ||
| - | |||
| - | maxRedirects: | ||
| - | |||
| - | validateStatus: | ||
| - | |||
| - | }); | ||
| - | |||
| - | otpHtml = otpPageRes.data; | ||
| - | |||
| - | otpCookies = mergeCookies(otpCookies, | ||
| - | |||
| - | } | ||
| - | |||
| - | // 3. OTP form should be present — extract its action URL | ||
| - | |||
| - | const otpActionMatch = otpHtml? | ||
| - | |||
| - | if (!otpActionMatch) { | ||
| - | |||
| - | return res.status(500).json({ | ||
| - | |||
| - | error: ' | ||
| - | |||
| - | }); | ||
| - | |||
| - | } | ||
| - | |||
| - | const otpActionUrl = otpActionMatch[1].replace(/&/ | ||
| - | |||
| - | const allCookies = otpCookies; | ||
| - | |||
| - | // Store session for OTP verification step | ||
| - | |||
| - | const sessionId = generateSessionId(); | ||
| - | |||
| - | otpSessions.set(sessionId, | ||
| - | |||
| - | otpActionUrl, | ||
| - | |||
| - | cookies: allCookies, | ||
| - | |||
| - | kcUrl, realm, clientId, redirectUri, | ||
| - | |||
| - | createdAt: Date.now(), | ||
| - | |||
| - | }); | ||
| - | |||
| - | return res.json({ requiresOTP: | ||
| - | |||
| - | } catch (err) { | ||
| - | |||
| - | const msg = err.response? | ||
| - | |||
| - | return res.status(500).json({ error: `Login failed: ${msg}` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | }); | ||
| - | |||
| - | /** | ||
| - | |||
| - | * Step 2: Verify OTP | ||
| - | |||
| - | * | ||
| - | |||
| - | * POST the OTP to Keycloak' | ||
| - | |||
| - | * exchange for tokens. | ||
| - | |||
| - | */ | ||
| - | |||
| - | app.post('/ | ||
| - | |||
| - | const { sessionId, otp } = req.body; | ||
| - | |||
| - | if (!sessionId || !otp) { | ||
| - | |||
| - | return res.status(400).json({ error: ' | ||
| - | |||
| - | } | ||
| - | |||
| - | const session = otpSessions.get(sessionId); | ||
| - | |||
| - | if (!session) { | ||
| - | |||
| - | return res.status(400).json({ error: ' | ||
| - | |||
| - | } | ||
| - | |||
| - | try { | ||
| - | |||
| - | const otpRes = await axios.post( | ||
| - | |||
| - | session.otpActionUrl, | ||
| - | |||
| - | qs.stringify({ ' | ||
| - | |||
| - | { | ||
| - | |||
| - | headers: { | ||
| - | |||
| - | ' | ||
| - | |||
| - | ' | ||
| - | |||
| - | }, | ||
| - | |||
| - | httpsAgent, | ||
| - | |||
| - | maxRedirects: | ||
| - | |||
| - | validateStatus: | ||
| - | |||
| - | } | ||
| - | |||
| - | ); | ||
| - | |||
| - | // Success: Keycloak redirects with authorization code | ||
| - | |||
| - | if (otpRes.status === 302 && otpRes.headers.location? | ||
| - | |||
| - | const code = new URL(otpRes.headers.location).searchParams.get(' | ||
| - | |||
| - | const { kcUrl, realm, clientId, redirectUri } = session; | ||
| - | |||
| - | const tokens = await exchangeCodeForTokens(kcUrl, | ||
| - | |||
| - | otpSessions.delete(sessionId); | ||
| - | |||
| - | return res.json({ success: true, tokens }); | ||
| - | |||
| - | } | ||
| - | |||
| - | // Invalid OTP — Keycloak returns the OTP form again | ||
| - | |||
| - | if (otpRes.status === 200) { | ||
| - | |||
| - | return res.status(401).json({ error: ' | ||
| - | |||
| - | } | ||
| - | |||
| - | return res.status(400).json({ error: `Unexpected response: HTTP ${otpRes.status}` }); | ||
| - | |||
| - | } catch (err) { | ||
| - | |||
| - | return res.status(500).json({ error: `OTP verification failed: ${err.message}` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | }); | ||
| - | |||
| - | /** | ||
| - | |||
| - | * Direct password login (no OTP) — uses Resource Owner Password Credentials grant | ||
| - | |||
| - | */ | ||
| - | |||
| - | app.post('/ | ||
| - | |||
| - | const { kcUrl, realm, clientId, username, password } = req.body; | ||
| - | |||
| - | if (!kcUrl || !realm || !clientId || !username || !password) { | ||
| - | |||
| - | return res.status(400).json({ error: 'All fields are required.' | ||
| - | |||
| - | } | ||
| - | |||
| - | try { | ||
| - | |||
| - | const tokenUrl = `${kcUrl}/ | ||
| - | |||
| - | const response = await axios.post( | ||
| - | |||
| - | tokenUrl, | ||
| - | |||
| - | qs.stringify({ grant_type: ' | ||
| - | |||
| - | { headers: { ' | ||
| - | |||
| - | ); | ||
| - | |||
| - | return res.json({ success: true, tokens: response.data }); | ||
| - | |||
| - | } catch (err) { | ||
| - | |||
| - | const msg = err.response? | ||
| - | |||
| - | return res.status(401).json({ error: `Login failed: ${msg}` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | }); | ||
| - | |||
| - | // ── Helpers ────────────────────────────────────────────────────────────────── | ||
| - | |||
| - | async function exchangeCodeForTokens(kcUrl, | ||
| - | |||
| - | const tokenUrl = `${kcUrl}/ | ||
| - | |||
| - | const res = await axios.post( | ||
| - | |||
| - | tokenUrl, | ||
| - | |||
| - | qs.stringify({ grant_type: ' | ||
| - | |||
| - | { headers: { ' | ||
| - | |||
| - | ); | ||
| - | |||
| - | return res.data; | ||
| - | |||
| - | } | ||
| - | |||
| - | function parseCookies(setCookieHeader) { | ||
| - | |||
| - | if (!setCookieHeader) return ''; | ||
| - | |||
| - | const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; | ||
| - | |||
| - | return arr.map(c => c.split(';' | ||
| - | |||
| - | } | ||
| - | |||
| - | function mergeCookies(existing, | ||
| - | |||
| - | const parts = [...new Set([ | ||
| - | |||
| - | ...existing.split('; | ||
| - | |||
| - | ...incoming.split('; | ||
| - | |||
| - | ])]; | ||
| - | |||
| - | return parts.join('; | ||
| - | |||
| - | } | ||
| - | |||
| - | function generateSessionId() { | ||
| - | |||
| - | return Math.random().toString(36).slice(2) + Date.now().toString(36); | ||
| - | |||
| - | } | ||
| - | |||
| - | // ── SMS OTP — proxied through Keycloak (OTP stored in Keycloak auth session) ── | ||
| - | |||
| - | async function sendExotelSms(sid, | ||
| - | |||
| - | const url = `https:// | ||
| - | |||
| - | const res = await axios.post(url, | ||
| - | |||
| - | headers: { ' | ||
| - | |||
| - | validateStatus: | ||
| - | |||
| - | }); | ||
| - | |||
| - | console.log(' | ||
| - | |||
| - | return res; | ||
| - | |||
| - | } | ||
| - | |||
| - | /** | ||
| - | |||
| - | * SMS OTP Step 1: Initiate Keycloak SMS OTP flow via sms-login-client | ||
| - | |||
| - | * Keycloak generates & stores the OTP — app never sees it. | ||
| - | |||
| - | * Keycloak calls / | ||
| - | |||
| - | */ | ||
| - | |||
| - | app.post('/ | ||
| - | |||
| - | const { kcUrl, realm, phone } = req.body; | ||
| - | |||
| - | const smsClientId = ' | ||
| - | |||
| - | if (!kcUrl || !realm || !phone) { | ||
| - | |||
| - | return res.status(400).json({ error: ' | ||
| - | |||
| - | } | ||
| - | |||
| - | // Look up username by phone attribute via admin API | ||
| - | |||
| - | let username; | ||
| - | |||
| - | try { | ||
| - | |||
| - | const tokenRes = await axios.post( | ||
| - | |||
| - | `${kcUrl}/ | ||
| - | |||
| - | qs.stringify({ grant_type: ' | ||
| - | |||
| - | { headers: { ' | ||
| - | |||
| - | ); | ||
| - | |||
| - | const adminToken = tokenRes.data.access_token; | ||
| - | |||
| - | // Normalize phone for search | ||
| - | |||
| - | const normalizedPhone = phone.startsWith(' | ||
| - | |||
| - | const usersRes = await axios.get( | ||
| - | |||
| - | `${kcUrl}/ | ||
| - | |||
| - | { params: { q: `phone: | ||
| - | |||
| - | ); | ||
| - | |||
| - | const users = usersRes.data; | ||
| - | |||
| - | const match = users.find(u => { | ||
| - | |||
| - | const p = (u.attributes? | ||
| - | |||
| - | return p === normalizedPhone; | ||
| - | |||
| - | }); | ||
| - | |||
| - | if (!match) { | ||
| - | |||
| - | return res.status(404).json({ error: 'No user found with this phone number.' | ||
| - | |||
| - | } | ||
| - | |||
| - | username = match.username; | ||
| - | |||
| - | } catch (err) { | ||
| - | |||
| - | const msg = err.response? | ||
| - | |||
| - | return res.status(500).json({ error: `Failed to look up user: ${msg}` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | const redirectUri = `${req.protocol}:// | ||
| - | |||
| - | const authUrl = `${kcUrl}/ | ||
| - | |||
| - | try { | ||
| - | |||
| - | // 1. GET Keycloak login page for sms-login-client | ||
| - | |||
| - | const authRes = await axios.get(authUrl, | ||
| - | |||
| - | params: { client_id: smsClientId, | ||
| - | |||
| - | httpsAgent, maxRedirects: | ||
| - | |||
| - | }); | ||
| - | |||
| - | if (authRes.status !== 200) { | ||
| - | |||
| - | return res.status(400).json({ error: `Keycloak returned HTTP ${authRes.status} — is sms-login-client created?` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | const loginHtml = authRes.data; | ||
| - | |||
| - | const actionMatch = loginHtml.match(/ | ||
| - | |||
| - | if (!actionMatch) { | ||
| - | |||
| - | return res.status(500).json({ error: 'Could not find login form in Keycloak response.' | ||
| - | |||
| - | } | ||
| - | |||
| - | const loginActionUrl = actionMatch[1].replace(/&/ | ||
| - | |||
| - | const loginCookies = parseCookies(authRes.headers[' | ||
| - | |||
| - | // 2. POST username → Keycloak triggers SMS OTP flow, calls webhook, sends SMS | ||
| - | |||
| - | const credRes = await axios.post( | ||
| - | |||
| - | loginActionUrl, | ||
| - | |||
| - | qs.stringify({ username }), | ||
| - | |||
| - | { | ||
| - | |||
| - | headers: { ' | ||
| - | |||
| - | httpsAgent, maxRedirects: | ||
| - | |||
| - | } | ||
| - | |||
| - | ); | ||
| - | |||
| - | // Direct code (no OTP step — should not happen with SMS flow) | ||
| - | |||
| - | if (credRes.status === 302 && credRes.headers.location? | ||
| - | |||
| - | const code = new URL(credRes.headers.location).searchParams.get(' | ||
| - | |||
| - | const tokens = await exchangeCodeForTokens(kcUrl, | ||
| - | |||
| - | return res.json({ success: true, tokens }); | ||
| - | |||
| - | } | ||
| - | |||
| - | // Follow redirect to OTP form if needed | ||
| - | |||
| - | let otpHtml = typeof credRes.data === ' | ||
| - | |||
| - | let otpCookies = mergeCookies(loginCookies, | ||
| - | |||
| - | if (credRes.status === 302 && credRes.headers.location && !credRes.headers.location.includes(' | ||
| - | |||
| - | const otpPageRes = await axios.get(credRes.headers.location, | ||
| - | |||
| - | headers: { ' | ||
| - | |||
| - | httpsAgent, maxRedirects: | ||
| - | |||
| - | }); | ||
| - | |||
| - | otpHtml = typeof otpPageRes.data === ' | ||
| - | |||
| - | otpCookies = mergeCookies(otpCookies, | ||
| - | |||
| - | } | ||
| - | |||
| - | // Debug: log what Keycloak returned | ||
| - | |||
| - | console.log(' | ||
| - | |||
| - | // console.log(' | ||
| - | |||
| - | // 3. Extract OTP form action URL | ||
| - | |||
| - | const otpActionMatch = otpHtml? | ||
| - | |||
| - | if (!otpActionMatch) { | ||
| - | |||
| - | return res.status(500).json({ | ||
| - | |||
| - | error: 'SMS OTP form not found. Ensure sms-login-client has Browser SMS OTP flow configured and cotrav theme is set.', | ||
| - | |||
| - | }); | ||
| - | |||
| - | } | ||
| - | |||
| - | const otpActionUrl = otpActionMatch[1].replace(/&/ | ||
| - | |||
| - | const sessionId = generateSessionId(); | ||
| - | |||
| - | otpSessions.set(sessionId, | ||
| - | |||
| - | otpActionUrl, | ||
| - | |||
| - | kcUrl, realm, clientId: smsClientId, | ||
| - | |||
| - | createdAt: Date.now(), | ||
| - | |||
| - | }); | ||
| - | |||
| - | return res.json({ requiresOTP: | ||
| - | |||
| - | } catch (err) { | ||
| - | |||
| - | const msg = err.response? | ||
| - | |||
| - | return res.status(500).json({ error: `SMS login failed: ${msg}` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | }); | ||
| - | |||
| - | /** | ||
| - | |||
| - | * SMS OTP Step 2: Submit OTP to Keycloak — Keycloak verifies it | ||
| - | |||
| - | */ | ||
| - | |||
| - | app.post('/ | ||
| - | |||
| - | const { sessionId, otp } = req.body; | ||
| - | |||
| - | if (!sessionId || !otp) return res.status(400).json({ error: ' | ||
| - | |||
| - | const session = otpSessions.get(sessionId); | ||
| - | |||
| - | if (!session) return res.status(400).json({ error: ' | ||
| - | |||
| - | try { | ||
| - | |||
| - | const otpRes = await axios.post( | ||
| - | |||
| - | session.otpActionUrl, | ||
| - | |||
| - | qs.stringify({ sms_otp: otp }), | ||
| - | |||
| - | { | ||
| - | |||
| - | headers: { ' | ||
| - | |||
| - | httpsAgent, maxRedirects: | ||
| - | |||
| - | } | ||
| - | |||
| - | ); | ||
| - | |||
| - | if (otpRes.status === 302 && otpRes.headers.location? | ||
| - | |||
| - | const code = new URL(otpRes.headers.location).searchParams.get(' | ||
| - | |||
| - | const { kcUrl, realm, clientId, redirectUri } = session; | ||
| - | |||
| - | const tokens = await exchangeCodeForTokens(kcUrl, | ||
| - | |||
| - | otpSessions.delete(sessionId); | ||
| - | |||
| - | return res.json({ success: true, tokens }); | ||
| - | |||
| - | } | ||
| - | |||
| - | if (otpRes.status === 200) { | ||
| - | |||
| - | return res.status(401).json({ error: ' | ||
| - | |||
| - | } | ||
| - | |||
| - | return res.status(400).json({ error: `Unexpected response: HTTP ${otpRes.status}` }); | ||
| - | |||
| - | } catch (err) { | ||
| - | |||
| - | return res.status(500).json({ error: `SMS OTP verification failed: ${err.message}` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | }); | ||
| - | |||
| - | // ── Exotel Webhook (called by Keycloak SMS SPI) ─────────────────────────────── | ||
| - | |||
| - | app.post('/ | ||
| - | |||
| - | let { phone, otp } = req.body; | ||
| - | |||
| - | if (!phone || !otp) return res.status(400).json({ error: 'phone and otp required' | ||
| - | |||
| - | // Normalize: ensure +91 prefix for Indian numbers | ||
| - | |||
| - | if (!phone.startsWith(' | ||
| - | |||
| - | phone = ' | ||
| - | |||
| - | } | ||
| - | |||
| - | const sid = process.env.EXOTEL_SID | ||
| - | |||
| - | const token = process.env.EXOTEL_API_TOKEN || ' | ||
| - | |||
| - | const from = process.env.EXOTEL_FROM | ||
| - | |||
| - | if (!sid || !token || !from) { | ||
| - | |||
| - | return res.status(500).json({ error: ' | ||
| - | |||
| - | } | ||
| - | |||
| - | const { name } = req.body; | ||
| - | |||
| - | const userName = name || ' | ||
| - | |||
| - | const message = `Dear ${userName}, | ||
| - | |||
| - | try { | ||
| - | |||
| - | await sendExotelSms(sid, | ||
| - | |||
| - | return res.json({ success: true }); | ||
| - | |||
| - | } catch (err) { | ||
| - | |||
| - | const msg = err.response? | ||
| - | |||
| - | return res.status(500).json({ error: `Exotel error: ${msg}` }); | ||
| - | |||
| - | } | ||
| - | |||
| - | }); | ||
| - | |||
| - | // ── Static + Start ──────────────────────────────────────────────────────────── | ||
| - | |||
| - | const path = require(' | ||
| - | |||
| - | app.use(express.static(path.join(__dirname, | ||
| - | |||
| - | // Redirect root to the main page | ||
| - | |||
| - | app.get('/', | ||
| - | |||
| - | res.sendFile(path.join(__dirname, | ||
| - | |||
| - | }); | ||
| - | |||
| - | app.listen(3000, | ||
| - | |||
| - | </ | ||
send_email_through_keyclock.1773043081.txt.gz · Last modified: by sonali
