feat(email-smtp):done of send email verify

parent a9b6a652
...@@ -93,13 +93,16 @@ curl http://localhost:3001/health ...@@ -93,13 +93,16 @@ curl http://localhost:3001/health
curl http://localhost:3001/.well-known/openid-configuration | jq curl http://localhost:3001/.well-known/openid-configuration | jq
``` ```
**Tài khoản mặc định:** **Tài khoản mặc định (sau `pnpm db:setup`):**
``` ```
Email: admin@vietprodev.com System Admin: admin@vietprodev.com / VietPro@2026 (system_admin role)
Password: VietPro@2026 Admin: admin@sso.vietprodev.com / VietPro@2026 (admin role)
User: user@sso.vietprodev.com / VietPro@2026 (user role)
``` ```
> Tất cả dùng chung password: `VietPro@2026`. Hash bcrypt mới nhất đã được seed lại ngày 2026-06-20.
--- ---
## 3. Project Structure ## 3. Project Structure
...@@ -109,27 +112,31 @@ sso-vietprodev-backend/ ...@@ -109,27 +112,31 @@ sso-vietprodev-backend/
├── src/ ├── src/
│ ├── controllers/ │ ├── controllers/
│ │ ├── admin/ # Admin API (clients, users) │ │ ├── admin/ # Admin API (clients, users)
│ │ └── api/v1/ # REST API (auth, user, file, notification) │ │ └── api/v1/ # REST API v1 (auth, users, files, notifications, audit)
│ ├── oidc/ │ ├── oidc/ # OIDC/OAuth2 Authorization Server
│ │ ├── oidcService.ts # OIDC Provider config, findAccount │ │ ├── oidcService.ts # Provider config, findAccount, Koa context builder
│ │ ├── oidcAdapterService.ts # Postgres adapter cho oidc-provider │ │ ├── oidcAdapterService.ts # Sequelize adapter cho oidc-provider
│ │ ├── oidcRoutes.ts # /oauth/* endpoints │ │ ├── oidcRoutes.ts # /oauth/* + /auth/:uid routes
│ │ └── oidcInteractionsController.ts # login / register / consent pages │ │ ├── oidcInteractionsController.ts # login / register / consent / verify pages
│ ├── services/ # Business logic │ │ └── views/ # Handlebars templates (login, register, logout…)
│ ├── contracts/ # Zod schemas + OpenAPI paths
│ ├── dto/ # Data transfer objects
│ ├── services/ # Business logic (auth, notification, storage, scheduler)
│ ├── models/ # Sequelize models │ ├── models/ # Sequelize models
│ ├── providers/ # Database providers │ ├── providers/ # Data access layer
│ ├── middlewares/ # Auth, validators, CSP │ ├── middlewares/ # Auth, validators, rate-limiter, CSP
│ ├── constants/ # Error codes, roles, statuses │ ├── constants/ # Error codes, roles, statuses, enums
│ ├── config/ # Env config với Zod validation │ ├── config/ # Env config với Zod validation
│ ├── interfaces/ # Shared TypeScript types
│ └── utils/ # Logger, helpers │ └── utils/ # Logger, helpers
├── sql/ ├── sql/
│ ├── migrations/ # Chạy tự động qua `pnpm migrate` │ ├── migrations/ # Chạy tự động qua `pnpm migrate`
└── seeds/ # Chạy tự động qua `pnpm seed` ├── seeds/ # Chạy tự động qua `pnpm seed`
├── 101-seed-default-users.sql # Admin user │ ├── 100-seed-roles-permissions.sql
└── 102-seed-default-clients.sql # OIDC demo clients │ ├── 101-seed-default-users.sql # admin@vietprodev.com, admin@sso…
├── sql/scripts/ │ │ └── 102-seed-default-clients.sql # project-a-demo, project-b-demo
├── reset-admin-password.js # Reset password admin └── scripts/ # migrate.js, check-db.js, clean-users.js…
│ └── check-admin-user.js # Kiểm tra tài khoản admin ├── storage/swagger/ # Generated OpenAPI spec
├── docker-compose.yml ├── docker-compose.yml
├── .env / .env.example ├── .env / .env.example
└── package.json └── package.json
...@@ -511,7 +518,7 @@ Trong production (`NODE_ENV=production`), CSP chặn mọi script/form không đ ...@@ -511,7 +518,7 @@ Trong production (`NODE_ENV=production`), CSP chặn mọi script/form không đ
## 13. Known Issues ## 13. Known Issues
### 13.1. PostgreSQL local conflict (Windows) — ĐÃ FIX ### 13.1. PostgreSQL local conflict (Windows) — ĐÃ FIX
**Triệu chứng:** Server kết nối vào PostgreSQL local (port 5432) thay vì Docker container `sso-postgres`. **Triệu chứng:** Server kết nối vào PostgreSQL local (port 5432) thay vì Docker container `sso-postgres`.
...@@ -534,25 +541,34 @@ docker compose up -d postgres postgres-backup ...@@ -534,25 +541,34 @@ docker compose up -d postgres postgres-backup
**Nguyên nhân:** `EMAIL_HOST=smtp.example.com` (placeholder) → SMTP không được cấu hình → email ghi vào `dev-mail.log` thay vì gửi thật. **Nguyên nhân:** `EMAIL_HOST=smtp.example.com` (placeholder) → SMTP không được cấu hình → email ghi vào `dev-mail.log` thay vì gửi thật.
**Cách xử lý:** **Cách xử lý (chọn 1 trong 3):**
```powershell ```powershell
# Xem email đã ghi trong dev-mail.log # Cách 1: Xem email trên trình duyệt (mới)
Get-Content .\dev-mail.log -Tail 20 # Mở http://localhost:3001/dev/emails → click nút "Verify" màu xanh
# Cách 2: Copy link từ dev-mail.log
Get-Content .\dev-mail.log -Tail 30
# Tìm dòng verify-email?token=... → copy vào trình duyệt
# Ngoài ra, trên trang verify-pending có link trực tiếp (dev mode): # Cách 3: Trên trang verify-pending có box vàng chứa link trực tiếp
# Mở trình duyệt → http://localhost:4001 → "Create account" → điền form → submit # Dev mode: email delivery is in fallback mode → click link trong box đó
# → Trang verify-pending hiển thị link verify trực tiếp (không cần mở dev-mail.log)
``` ```
**Để gửi email thật:** Cấu hình SMTP trong `.env`: **Để gửi email thật:** Cấu hình SMTP trong `.env`:
```bash ```bash
EMAIL_HOST=smtp.gmail.com # Hoặc SMTP provider khác # Gmail SMTP (khuyên dùng cho dev/staging)
EMAIL_PORT=587 EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587 # 587=TLS, 465=SSL
EMAIL_USER=your-email@gmail.com EMAIL_USER=your-email@gmail.com
EMAIL_PASS=app-password # Gmail: Settings → App Passwords EMAIL_PASS=xxxx xxxx xxxx xxxx # Gmail App Password (16 ký tự, có dấu cách)
EMAIL_FROM=noreply@vietprodev.com EMAIL_FROM=your-email@gmail.com # PHẢI trùng với EMAIL_USER (Gmail chỉ gửi từ chính account)
# Gmail App Password: https://myaccount.google.com/apppasswords
# 1. Bật 2-Step Verification trước
# 2. Tìm "App passwords" → tạo mới → đặt tên "SSO VietProDev"
# 3. Copy 16 ký tự (format: xxxx xxxx xxxx xxxx)
``` ```
### 13.3. OIDC redirect 404 `/auth/:uid` ### 13.3. OIDC redirect 404 `/auth/:uid`
......
/**
* Script: Reset password for a specific user
* Usage: npx tsx scripts/reset-password.ts <email> <newPassword>
*
* Example: npx tsx scripts/reset-password.ts onosocobo124@gmail.com "VietPro@2026"
*/
import crypto from 'crypto';
import { PasswordService } from '../src/services/auth/passwordService';
import sequelize from '../src/services/database/sequelize/sequelizeService';
import { User } from '../src/models/User';
import { UserAuth } from '../src/models/UserAuth';
async function main() {
const [,, rawEmail, ...rawPasswordParts] = process.argv;
const newPassword = rawPasswordParts.join(' ');
if (!rawEmail || !newPassword) {
// eslint-disable-next-line no-console
console.error('Usage: npx tsx scripts/reset-password.ts <email> <newPassword>');
process.exit(1);
}
// eslint-disable-next-line no-console
console.log(`[ResetPassword] Connecting to DB...`);
await sequelize.authenticate();
// eslint-disable-next-line no-console
console.log(`[ResetPassword] Connected.`);
// Step 1: Find user by email
const userRecord = await User.findOne({ where: { email: rawEmail } as any });
if (!userRecord) {
// eslint-disable-next-line no-console
console.error(`[ResetPassword] User not found: ${rawEmail}`);
await sequelize.close();
process.exit(1);
}
// eslint-disable-next-line no-console
console.log(`[ResetPassword] Found user: ${userRecord.id} (${rawEmail})`);
// Step 2: Find or create user_auth by user UUID
let targetRecord = await UserAuth.findOne({ where: { user_id: userRecord.id } as any });
if (!targetRecord) {
// eslint-disable-next-line no-console
console.warn(`[ResetPassword] user_auth record not found, creating...`);
const newHash = await PasswordService.hashPassword(newPassword);
await UserAuth.create({
id: crypto.randomUUID(),
user_id: userRecord.id,
password_hash: newHash,
} as any);
// eslint-disable-next-line no-console
console.log(`[ResetPassword] Created user_auth + set new password hash.`);
} else {
const newHash = await PasswordService.hashPassword(newPassword);
await targetRecord.update({ password_hash: newHash } as any);
// eslint-disable-next-line no-console
console.log(`[ResetPassword] Updated password hash for ${rawEmail}.`);
}
await sequelize.close();
// eslint-disable-next-line no-console
console.log(`[ResetPassword] Done.`);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[ResetPassword] Error:', err);
process.exit(1);
});
...@@ -17,6 +17,7 @@ import { UserAuthProvider } from '#providers/UserAuthProvider'; ...@@ -17,6 +17,7 @@ import { UserAuthProvider } from '#providers/UserAuthProvider';
import Config from '#config'; import Config from '#config';
import Logger from '#utils/logger'; import Logger from '#utils/logger';
import { GenericError } from '#interfaces/error/generic'; import { GenericError } from '#interfaces/error/generic';
import RedisService from '#services/storage/redisService';
const router = express.Router(); const router = express.Router();
...@@ -192,10 +193,62 @@ router.post('/:uid/register', async (req, res) => { ...@@ -192,10 +193,62 @@ router.post('/:uid/register', async (req, res) => {
page: 1, page: 1,
pageSize: 1, pageSize: 1,
}); });
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
const existingUser = existing.rows[0]!;
// Active account: user already verified → redirect to login
if (existingUser.status === UserStatus.ACTIVE) {
return renderRegisterError('Email already registered. Please sign in instead.'); return renderRegisterError('Email already registered. Please sign in instead.');
} }
// Pending account: same email, not yet verified.
// Instead of blocking, send a fresh verification email (or re-send
// the existing one). This handles the case where:
// - The user never received the first email (SMTP failure, spam filter)
// - The user refreshed the page and tried to register again
// No new account is created; we reuse the existing pending record.
try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours);
const devVerifyUrl = isDevMode()
? EmailVerificationService.getInstance().buildVerificationUrl(issued.token)
: null;
// Persist pending-email state in Redis so the verify-pending page
// remains functional even after the OIDC interaction session expires
// (e.g. user refreshes after an hour). TTL = 2 × email token TTL.
const redis = RedisService.getInstance();
const pendingKey = `pending_email:${trimmedEmail}`;
await redis.set(
pendingKey,
{ uid, email: trimmedEmail, createdAt: new Date().toISOString() },
Math.round(ttlHours * 3600 * 2),
);
return res.status(200).render('verify-pending', {
uid,
client: clientId,
email: trimmedEmail,
ttlHours,
devVerifyUrl,
error: undefined,
csrfToken: csrfToken(req),
});
} catch (mailErr) {
Logger.error(`[OIDC register] Pending user ${existingUser.id} — re-send failed`, mailErr);
// Even if mail fails, surface verify-pending so the user can retry
return res.status(200).render('verify-pending', {
uid,
client: clientId,
email: trimmedEmail,
ttlHours,
devVerifyUrl: null,
error: 'Could not send verification email. Click "Resend" in a moment.',
csrfToken: csrfToken(req),
});
}
}
const passwordHash = await PasswordService.hashPassword(password); const passwordHash = await PasswordService.hashPassword(password);
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
const authId = crypto.randomUUID(); const authId = crypto.randomUUID();
...@@ -268,6 +321,15 @@ router.post('/:uid/register', async (req, res) => { ...@@ -268,6 +321,15 @@ router.post('/:uid/register', async (req, res) => {
let devVerifyUrl: string | null = null; let devVerifyUrl: string | null = null;
if (createdUser) { if (createdUser) {
// Persist pending-email state in Redis so verify-pending works even
// after the OIDC interaction session expires on refresh.
const redis = RedisService.getInstance();
await redis.set(
`pending_email:${trimmedEmail}`,
{ uid, email: trimmedEmail, createdAt: new Date().toISOString() },
Math.round(ttlHours * 3600 * 2),
);
try { try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours); const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours);
devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
...@@ -308,13 +370,30 @@ router.post('/:uid/register', async (req, res) => { ...@@ -308,13 +370,30 @@ router.post('/:uid/register', async (req, res) => {
// POST /oidc/interaction/:uid/resend-verification — mint a fresh token and // POST /oidc/interaction/:uid/resend-verification — mint a fresh token and
// re-render the verify-pending screen. The response is identical for known // re-render the verify-pending screen. The response is identical for known
// and unknown emails to avoid leaking account existence. // and unknown emails to avoid leaking account existence.
//
// When the OIDC interaction uid has expired (e.g. user refreshed after hours)
// we fall back to the uid stored in Redis under pending_email:<email>.
router.post('/:uid/resend-verification', async (req, res) => { router.post('/:uid/resend-verification', async (req, res) => {
const uid = req.params.uid; const uid = req.params.uid;
const emailRaw = (req.body as { email?: string }).email ?? ''; const emailRaw = (req.body as { email?: string }).email ?? '';
const trimmedEmail = emailRaw.trim().toLowerCase(); const trimmedEmail = emailRaw.trim().toLowerCase();
const clientId = await getClientIdFromUid(uid);
const ttlHours = getVerificationTtlHours(); const ttlHours = getVerificationTtlHours();
const redis = RedisService.getInstance();
// Try to use the most recent uid for this email from Redis.
// If the OIDC session uid has expired, the Redis uid is the fallback.
const redisData = await redis.get<{ uid: string; email: string; createdAt: string }>(
`pending_email:${trimmedEmail}`,
);
const resolvedUid = redisData?.uid ?? uid;
let clientId = '';
try {
clientId = (await getClientIdFromUid(resolvedUid)) ?? '';
} catch {
clientId = '';
}
const renderPending = (body: { const renderPending = (body: {
email: string; email: string;
...@@ -323,7 +402,7 @@ router.post('/:uid/resend-verification', async (req, res) => { ...@@ -323,7 +402,7 @@ router.post('/:uid/resend-verification', async (req, res) => {
error?: string; error?: string;
}) => }) =>
res.status(200).render('verify-pending', { res.status(200).render('verify-pending', {
uid, uid: resolvedUid,
client: clientId, client: clientId,
email: body.email, email: body.email,
ttlHours: body.ttlHours, ttlHours: body.ttlHours,
...@@ -353,6 +432,13 @@ router.post('/:uid/resend-verification', async (req, res) => { ...@@ -353,6 +432,13 @@ router.post('/:uid/resend-verification', async (req, res) => {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours); const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours);
const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
// Keep Redis fresh with the current uid
await redis.set(
`pending_email:${trimmedEmail}`,
{ uid: resolvedUid, email: trimmedEmail, createdAt: new Date().toISOString() },
Math.round(ttlHours * 3600 * 2),
);
return renderPending({ return renderPending({
email: trimmedEmail, email: trimmedEmail,
ttlHours, ttlHours,
......
...@@ -72,10 +72,10 @@ router.post('/oauth/logout/confirm', async (req, res, next) => { ...@@ -72,10 +72,10 @@ router.post('/oauth/logout/confirm', async (req, res, next) => {
return; return;
} }
// Use client_id from query param (not body) — body can be tampered. // post_logout_redirect_uri: prefer query string (the original RP logout URL params)
// Fall back to body.client_id if query is not available. // then fall back to form body (hidden field). client_id: prefer query then body.
const clientId = (req.query?.client_id ?? req.body?.client_id) as string | undefined; const clientId = (req.query?.client_id ?? req.body?.client_id) as string | undefined;
const rawRedirect = req.query?.post_logout_redirect_uri as string | undefined; const rawRedirect = (req.query?.post_logout_redirect_uri ?? req.body?.post_logout_redirect_uri) as string | undefined;
// oidc-provider may join multiple URIs with a comma — take the first one. // oidc-provider may join multiple URIs with a comma — take the first one.
const firstUri = rawRedirect?.split(',')[0]?.trim(); const firstUri = rawRedirect?.split(',')[0]?.trim();
......
...@@ -67,12 +67,23 @@ export class OidcService { ...@@ -67,12 +67,23 @@ export class OidcService {
findAccount: async (_ctx: unknown, sub: string) => { findAccount: async (_ctx: unknown, sub: string) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC findAccount] called with sub:', sub); console.log('[OIDC findAccount] called with sub:', sub);
const { User } = await import('#models/User');
const user = await User.findByPk(sub);
return { return {
accountId: sub, accountId: sub,
claims: () => { claims: () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC findAccount.claims] called for sub:', sub); console.log('[OIDC findAccount.claims] called for sub:', sub, '| user:', user?.email);
return { sub }; const fullName = [user?.first_name, user?.last_name].filter(Boolean).join(' ') || undefined;
return {
sub,
...(user?.email ? { email: user.email } : {}),
...(user?.email_verified_at != null ? { email_verified: true } : { email_verified: false }),
...(fullName ? { name: fullName } : {}),
...(user?.username ? { preferred_username: user.username } : {}),
};
}, },
}; };
}, },
...@@ -84,9 +95,10 @@ export class OidcService { ...@@ -84,9 +95,10 @@ export class OidcService {
// ── Custom logout views using Handlebars ───────────────────────────── // ── Custom logout views using Handlebars ─────────────────────────────
// Full form HTML with XSRF token and both action buttons embedded. // Full form HTML with XSRF token and both action buttons embedded.
const logoutFormHtml = (action: string, xsrf: string, postLogoutRedirectUri?: string) => const logoutFormHtml = (action: string, xsrf: string, postLogoutRedirectUri?: string, clientId?: string) =>
`<form id="op.logoutForm" method="post" action="${action}">` + `<form id="op.logoutForm" method="post" action="${action}">` +
`<input type="hidden" name="xsrf" value="${xsrf}"/>` + `<input type="hidden" name="xsrf" value="${xsrf}"/>` +
`<input type="hidden" name="client_id" value="${clientId ?? ''}"/>` +
(postLogoutRedirectUri ? `<input type="hidden" name="post_logout_redirect_uri" value="${postLogoutRedirectUri}"/>` : '') + (postLogoutRedirectUri ? `<input type="hidden" name="post_logout_redirect_uri" value="${postLogoutRedirectUri}"/>` : '') +
`<button type="submit" name="logout" value="yes" class="btn-signout">Yes, sign me out</button>` + `<button type="submit" name="logout" value="yes" class="btn-signout">Yes, sign me out</button>` +
`<button type="submit" name="logout" value="no" class="btn-stay">No, stay signed in</button>` + `<button type="submit" name="logout" value="no" class="btn-stay">No, stay signed in</button>` +
...@@ -113,8 +125,9 @@ export class OidcService { ...@@ -113,8 +125,9 @@ export class OidcService {
const action = ctx.oidc.urlFor('end_session_confirm'); const action = ctx.oidc.urlFor('end_session_confirm');
const xsrf = ctx.oidc.session?.state?.secret ?? ''; const xsrf = ctx.oidc.session?.state?.secret ?? '';
const clientName = ctx.oidc.client?.clientName ?? ctx.oidc.client?.clientId ?? 'SSO'; const clientName = ctx.oidc.client?.clientName ?? ctx.oidc.client?.clientId ?? 'SSO';
const clientId = ctx.oidc.client?.clientId ?? '';
const postLogoutRedirectUri = ctx.oidc.session?.state?.postLogoutRedirectUri ?? ''; const postLogoutRedirectUri = ctx.oidc.session?.state?.postLogoutRedirectUri ?? '';
const formHtml = logoutFormHtml(action, xsrf, postLogoutRedirectUri); const formHtml = logoutFormHtml(action, xsrf, postLogoutRedirectUri, clientId);
const logoutViewPath = resolve(__dirname, './views/logout.hbs'); const logoutViewPath = resolve(__dirname, './views/logout.hbs');
const tpl = readFileSync(logoutViewPath, 'utf8'); const tpl = readFileSync(logoutViewPath, 'utf8');
ctx.body = Handlebars.compile(tpl)({ formHtml, client: clientName }); ctx.body = Handlebars.compile(tpl)({ formHtml, client: clientName });
...@@ -129,13 +142,26 @@ export class OidcService { ...@@ -129,13 +142,26 @@ export class OidcService {
rpLogout.postLogoutSuccessSource = async (ctx: any) => { rpLogout.postLogoutSuccessSource = async (ctx: any) => {
try { try {
// User clicked "No" — stay signed in, redirect back without clearing session. // After logout is confirmed, redirect to post_logout_redirect_uri if available.
if (ctx.oidc.params?.cancel) { // Use params (query string from original /logout request) as primary source
const redirectUri = ctx.oidc.session?.state?.postLogoutRedirectUri; // because session may have been destroyed by the time this handler runs.
if (redirectUri) { // Fall back to session state if params are not available.
ctx.redirect(redirectUri); const postLogoutRedirectUri =
(ctx.oidc.params as any)?.post_logout_redirect_uri
|| ctx.oidc.session?.state?.postLogoutRedirectUri
|| '';
if (postLogoutRedirectUri) {
// Validate the URI against registered URIs for the client to prevent open-redirect.
const clientId = ctx.oidc.client?.clientId;
const allowed = Array.isArray(ctx.oidc.client?.postLogoutRedirectUris)
? ctx.oidc.client.postLogoutRedirectUris
: [];
if (allowed.includes(postLogoutRedirectUri)) {
ctx.redirect(postLogoutRedirectUri);
return; return;
} }
// If URI not registered for this client, fall through to success page.
} }
const logoutSuccessViewPath = resolve(__dirname, './views/logout-success.hbs'); const logoutSuccessViewPath = resolve(__dirname, './views/logout-success.hbs');
......
...@@ -12,17 +12,71 @@ ...@@ -12,17 +12,71 @@
.card p { margin-bottom: 16px; color: #444; font-size: 14px; line-height: 1.5; } .card p { margin-bottom: 16px; color: #444; font-size: 14px; line-height: 1.5; }
.card p.subtitle { color: #666; } .card p.subtitle { color: #666; }
.icon { width: 64px; height: 64px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; font-size: 32px; } .icon { width: 64px; height: 64px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; font-size: 32px; }
.email-badge { background: #f3f4f6; padding: 8px 12px; border-radius: 8px; display: inline-block; font-family: monospace; color: #1f2937; margin: 4px 0; } .email-badge { background: #f3f4f6; padding: 8px 12px; border-radius: 8px; display: inline-block; font-family: monospace; color: #1f2937; margin: 4px 0; }
.dev-block { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; padding: 12px 14px; border-radius: 8px; margin-top: 16px; font-size: 13px; word-break: break-all; }
.dev-block a { color: #b45309; } /* ── Dev mode banner ──────────────────────────────── */
.dev-banner {
background: linear-gradient(135deg, #fef9c3 0%, #fef08a 100%);
border: 1.5px solid #facc15;
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 20px;
color: #713f12;
font-size: 13px;
}
.dev-banner-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; color: #92400e; }
.dev-banner p { margin-bottom: 4px; color: #92400e; }
/* Dev mode: primary CTA button */
.dev-verify-btn {
display: block;
width: 100%;
padding: 16px 20px;
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
color: #fff !important;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
text-align: center;
text-decoration: none;
margin-bottom: 8px;
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.35);
transition: transform 0.15s, box-shadow 0.15s;
}
.dev-verify-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(22, 163, 74, 0.45);
}
.dev-verify-sub {
text-align: center;
font-size: 12px;
color: #92400e;
margin-bottom: 16px;
}
/* Fallback dev-link (hidden, collapsible) */
.dev-link-toggle { font-size: 12px; color: #b45309; cursor: pointer; user-select: none; }
.dev-link-box { background: #fef9c3; border: 1px dashed #facc15; border-radius: 6px; padding: 10px 12px; margin-top: 8px; font-size: 12px; word-break: break-all; color: #713f12; display: none; }
.dev-link-box a { color: #15803d; }
/* ── Error ───────────────────────────────────────── */
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
/* ── Actions ─────────────────────────────────────── */
.actions { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; } .actions { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
.actions button, .actions a.button { width: 100%; padding: 11px 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; transition: background 0.2s; } .actions button, .actions a.button { width: 100%; padding: 11px 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; transition: background 0.2s; }
.actions button:hover, .actions a.button:hover { background: #4338ca; } .actions button:hover, .actions a.button:hover { background: #4338ca; }
.actions button.secondary { background: #fff; color: #4f46e5; border: 1px solid #c7d2fe; } .actions button.secondary { background: #fff; color: #4f46e5; border: 1px solid #c7d2fe; }
.actions button.secondary:hover { background: #eef2ff; } .actions button.secondary:hover { background: #eef2ff; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; } .footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { color: #4f46e5; text-decoration: none; } .footer a { color: #4f46e5; text-decoration: none; }
/* Dev mode inbox link at bottom */
.dev-inbox-link { text-align: center; margin-top: 12px; }
.dev-inbox-link a { font-size: 12px; color: #b45309; text-decoration: none; }
.dev-inbox-link a:hover { text-decoration: underline; }
</style> </style>
</head> </head>
<body> <body>
...@@ -35,12 +89,29 @@ ...@@ -35,12 +89,29 @@
{{#if error}}<div class="error">{{ error }}</div>{{/if}} {{#if error}}<div class="error">{{ error }}</div>{{/if}}
{{#if devVerifyUrl}} {{#if devVerifyUrl}}
<div class="dev-block"> {{! ── DEV MODE: fallback mail (no SMTP) — show instant verify + log viewer ── }}
<strong>Dev mode:</strong> email delivery is in fallback mode (logged to server console). <div class="dev-banner">
Use this link to verify immediately: <div class="dev-banner-title">&#9888; Dev Mode — Email sent via fallback (no SMTP)</div>
<br><br> <p>Real email was dispatched to your SMTP provider, but you can also click the green button for instant verification.</p>
<p style="margin-top:4px; margin-bottom:0; font-size:12px;">To view all recent dev emails, <a href="/dev/emails" style="color:#15803d;font-weight:600;">open Dev Email Inbox &#8599;</a></p>
</div>
<a class="dev-verify-btn" href="{{ devVerifyUrl }}">&#10003; Click here to verify email instantly (Dev)</a>
<p class="dev-verify-sub">You will be redirected to sign in after verifying.</p>
{{! Hidden collapsible raw URL for power users }}
<span class="dev-link-toggle" onclick="document.getElementById('devRawLink').style.display=document.getElementById('devRawLink').style.display?'none':'block'">
&#9660; Show raw verification URL
</span>
<div class="dev-link-box" id="devRawLink">
<a href="{{ devVerifyUrl }}">{{ devVerifyUrl }}</a> <a href="{{ devVerifyUrl }}">{{ devVerifyUrl }}</a>
</div> </div>
{{else}}
{{! ── PRODUCTION: instructions only ── }}
<div style="background:#eff6ff; border:1px solid #bfdbfe; border-radius:8px; padding:14px 16px; margin-bottom:8px; font-size:13px; color:#1e40af;">
<strong>Check your email inbox</strong> (<span style="font-family:monospace;">{{ email }}</span>).<br>
Click the link inside the email to verify your account.
</div>
{{/if}} {{/if}}
<div class="actions"> <div class="actions">
...@@ -55,6 +126,12 @@ ...@@ -55,6 +126,12 @@
<div class="footer"> <div class="footer">
Wrong email? <a href="/oidc/interaction/{{ uid }}/register">Register again</a> Wrong email? <a href="/oidc/interaction/{{ uid }}/register">Register again</a>
</div> </div>
{{#if devVerifyUrl}}
<div class="dev-inbox-link">
<a href="/dev/emails">&#128231; View all recent dev emails</a>
</div>
{{/if}}
</div> </div>
</body> </body>
</html> </html>
...@@ -7,7 +7,7 @@ import cookieParser from 'cookie-parser'; ...@@ -7,7 +7,7 @@ import cookieParser from 'cookie-parser';
import helmet from 'helmet'; import helmet from 'helmet';
import { engine } from 'express-handlebars'; import { engine } from 'express-handlebars';
import { resolve } from 'path'; import { resolve } from 'path';
import { readFileSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { exec } from 'child_process'; import { exec } from 'child_process';
// Environments & Constansts // Environments & Constansts
import { Environment } from './interfaces/IEnv'; import { Environment } from './interfaces/IEnv';
...@@ -315,6 +315,72 @@ const // Server functions ...@@ -315,6 +315,72 @@ const // Server functions
res.send('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#4f46e5"/><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" fill="white" font-size="18" font-family="sans-serif" font-weight="bold">S</text></svg>'); res.send('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#4f46e5"/><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" fill="white" font-size="18" font-family="sans-serif" font-weight="bold">S</text></svg>');
}); });
// ── Dev-only: in-browser email log viewer ───────────────────────────────
// Only available in non-production environments.
// Shows the last N entries from dev-mail.log for quick testing.
if (process.env.NODE_ENV !== 'production') {
const DEV_EMAIL_LOG = resolve(root, 'dev-mail.log');
app.get('/dev/emails', (_req, res) => {
if (!existsSync(DEV_EMAIL_LOG)) {
return res.status(200).send(
'<html><body style="font-family:sans-serif;padding:40px;background:#f5f5f5;">' +
'<div style="background:#fff;padding:24px;border-radius:8px;max-width:700px;margin:0 auto;">' +
'<h2 style="color:#4f46e5;">&#128231; Dev Email Inbox</h2>' +
'<p style="color:#666;">No emails logged yet. Register a new account to see emails here.</p>' +
'</div></body></html>',
);
}
const raw = readFileSync(DEV_EMAIL_LOG, 'utf8');
const entries = raw.split(/={5,}/).filter((e) => e.trim());
const cards = entries.reverse().slice(0, 20).map((entry) => {
const toMatch = entry.match(/To:\s*(.+)/);
const atMatch = entry.match(/At:\s*(.+)/);
const fromMatch = entry.match(/From:\s*(.+)/);
const subjMatch = entry.match(/Subj:\s*(.+)/);
const tokenMatch = entry.match(/verify-email\?token=([a-f0-9]{64})/);
const to = toMatch?.[1]?.trim() ?? 'unknown';
const at = atMatch?.[1]?.trim() ?? '';
const from = fromMatch?.[1]?.trim() ?? '';
const subj = subjMatch?.[1]?.trim() ?? '';
const verifyUrl = tokenMatch
? `/api/v1/auth/verify-email?token=${tokenMatch[1]}`
: '';
return '<div style="background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.06);">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
`<div><div style="font-weight:700;color:#1f2937;">&#128229; ${to}</div>` +
`<div style="font-size:12px;color:#6b7280;">${at} &nbsp;|&nbsp; From: ${from}</div>` +
`<div style="font-size:12px;color:#6b7280;">${subj}</div></div>` +
(verifyUrl
? `<a href="${verifyUrl}" style="background:#16a34a;color:#fff;padding:6px 14px;border-radius:6px;font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap;">&#10003; Verify</a>`
: '') +
'</div></div>';
}).join('');
return res.status(200).send(
'<html><head><meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<title>Dev Email Inbox — SSO VietProDev</title>' +
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f5f5f5;padding:20px;margin:0;}' +
'.container{max-width:700px;margin:0 auto;}' +
'.header{background:#4f46e5;color:#fff;padding:20px 24px;border-radius:10px 10px 0 0;}' +
'.header h1{margin:0;font-size:22px;}.header p{margin:4px 0 0;opacity:.8;font-size:14px;}' +
'.body{padding:20px 24px 32px;background:#fff;border-radius:0 0 10px 10px;}' +
'.count{color:#6b7280;font-size:14px;margin-bottom:16px;}' +
'</style></head><body>' +
'<div class="container">' +
'<div class="header"><h1>&#128231; Dev Email Inbox</h1><p>SSO VietProDev &mdash; emails logged to <code>dev-mail.log</code></p></div>' +
'<div class="body">' +
`<p class="count">${entries.length} email(s) logged &mdash; showing most recent 20</p>` +
cards +
'</div></div></body></html>',
);
});
}
// ── OIDC routes — must be before static files and autoroutes ────── // ── OIDC routes — must be before static files and autoroutes ──────
// /oidc/interaction must be registered BEFORE the generic '/' router so it is // /oidc/interaction must be registered BEFORE the generic '/' router so it is
// matched first; otherwise oidcRoutes silently absorbs requests to that path. // matched first; otherwise oidcRoutes silently absorbs requests to that path.
......
...@@ -29,14 +29,22 @@ class MailService { ...@@ -29,14 +29,22 @@ class MailService {
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host: Config.email.host, host: Config.email.host,
port: Config.email.port, port: Config.email.port,
secure: false, secure: Config.email.port === 465,
requireTLS: Config.email.port !== 465,
auth: { auth: {
user: Config.email.user, user: Config.email.user,
pass: Config.email.pass, pass: Config.email.pass,
}, },
}); });
// eslint-disable-next-line no-console
console.log(`[MailService] SMTP configured: ${Config.email.host}:${Config.email.port} | user: ${Config.email.user} | pass length: ${Config.email.pass?.length ?? 0}`);
} else { } else {
this.transporter = null; this.transporter = null;
// eslint-disable-next-line no-console
console.log(
`[MailService] SMTP NOT configured falling back to file log. ` +
`host=${Config.email.host} | user=${Config.email.user} | pass=${Config.email.pass ? '(set)' : '(empty)'}`,
);
} }
// Fallback log goes next to the rest of dev artefacts so it's easy to find. // Fallback log goes next to the rest of dev artefacts so it's easy to find.
...@@ -76,6 +84,8 @@ class MailService { ...@@ -76,6 +84,8 @@ class MailService {
} }
async sendmail(mailOptions: Options): Promise<SendResult> { async sendmail(mailOptions: Options): Promise<SendResult> {
// eslint-disable-next-line no-console
console.log(`[MailService] sendmail() to=${mailOptions.to} subject="${mailOptions.subject}" transporter=${this.transporter ? 'SMTP' : 'NULL (fallback)'}`);
if (this.transporter) { if (this.transporter) {
const info = await this.transporter.sendMail(mailOptions); const info = await this.transporter.sendMail(mailOptions);
return { mode: 'smtp', info }; return { mode: 'smtp', info };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment