Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/deploy_prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,19 @@ jobs:
secrets: inherit
with:
environment: production

# Kubernetes deploy. Activated when the repo variable DEPLOY_PROVIDER is
# 'k8s' — mutually exclusive with the Vercel job above. Reuses the
# platform-grade deploy_k8s.yaml: build → push GHCR → envsubst manifests
# under .deploy/k8s-platform/ → kubectl apply → wait for rollout. Reads
# K8S_TOKEN / K8S_INGRESS_HOST / K8S_INGRESS_CLASS / K8S_NAMESPACE /
# (optional) K8S_TLS_ISSUER from repo secrets. For Ever Works' own
# k8s-works cluster, leave K8S_TLS_ISSUER unset — Cloudflare terminates
# TLS upstream and the workflow emits the matching nginx annotations
# (ssl-redirect: "false"), matching every other *.ever.works ingress.
K8s:
if: ${{ vars.DEPLOY_PROVIDER == 'k8s' }}
uses: ./.github/workflows/deploy_k8s.yaml
secrets: inherit
with:
environment: production
200 changes: 200 additions & 0 deletions apps/web-e2e/verify-deactivation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* End-to-end verification of account deactivation / reactivation.
* Uses selectors and timing from global-setup.ts.
*/

import { chromium } from '@playwright/test';

const BASE = 'http://localhost:3000';
const EMAIL = `e2e-deactivate-${Date.now()}@test.local`;
const PASSWORD = 'TestClient123!';
const NAME = 'Deactivation Tester';

let passed = 0;
let failed = 0;
const findings = [];

const ok = (label, detail = '') => { passed++; console.log(` ✅ ${label}${detail ? ' — ' + detail : ''}`); };
const fail = (label, detail = '') => { failed++; console.log(` ❌ ${label}${detail ? ' — ' + detail : ''}`); };
const probe = (label, detail = '') => console.log(` 🔍 ${label}${detail ? ' — ' + detail : ''}`);
const warn = (label) => { findings.push(`⚠️ ${label}`); console.log(` ⚠️ ${label}`); };
const note = (label) => { findings.push(` ${label}`); console.log(` ${label}`); };

async function bodyText(page) {
return page.evaluate(() => document.body.innerText).catch(() => '');
}

async function toastText(page) {
return page.evaluate(() =>
[...document.querySelectorAll('[data-sonner-toast], [role="status"], [role="alert"]')]
.map(t => t.textContent).join(' ')
).catch(() => '');
}

async function run() {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ baseURL: BASE });
const page = await ctx.newPage();

const consoleErrors = [];
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });

try {
// ── Step 1: Register a fresh throwaway account ──────────────────────
console.log('\n[Step 1] Register fresh test account');
await page.goto('/auth/register', { waitUntil: 'domcontentloaded' });
await page.locator('#name').waitFor({ state: 'visible', timeout: 15000 });
await page.locator('#name').fill(NAME);
await page.locator('#email').fill(EMAIL);
await page.locator('#password').fill(PASSWORD);
await page.locator('#password').press('Enter');

try {
await page.waitForURL(/\/client\/dashboard/, { timeout: 60000, waitUntil: 'domcontentloaded' });
ok('Registered and auto-logged in', page.url());
} catch {
fail('Did not reach /client/dashboard after sign-up', page.url());
console.log(' Body:', (await bodyText(page)).slice(0, 300));
await browser.close();
process.exit(1);
}

// ── Step 2: Go to danger zone ───────────────────────────────────────
console.log('\n[Step 2] Navigate to danger-zone settings');
await page.goto('/client/settings/danger-zone', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);

if (page.url().includes('danger-zone')) {
ok('Danger zone page loaded', page.url());
} else {
fail('Could not reach danger-zone', page.url());
}

// ── Step 3: Open deactivate modal ───────────────────────────────────
console.log('\n[Step 3] Open Deactivate Account modal');
const deactivateBtn = page.getByRole('button', { name: /deactivate\s*account/i }).first();
await deactivateBtn.waitFor({ state: 'visible', timeout: 10000 });
ok('Deactivate button visible');
await deactivateBtn.click();
await page.waitForTimeout(700);

const pwInModal = page.locator('[role="dialog"] input[type="password"], [aria-modal="true"] input[type="password"]').first();
if (await pwInModal.isVisible().catch(() => false)) {
ok('Confirmation modal opened with password field');
} else {
warn('Modal role="dialog" not found — checking for password field anyway');
}

// ── Step 4: Wrong password ───────────────────────────────────────────
console.log('\n[Step 4] 🔍 Wrong password → must show "Incorrect password", not "User not found"');
const pwField = page.locator('input[autocomplete="current-password"]').last();
await pwField.fill('WrongPassword99!');
await page.locator('button[type="submit"]').last().click();
await page.waitForTimeout(3000);

const bodyW = await bodyText(page);
const toastW = await toastText(page);
const combined = bodyW + ' ' + toastW;

if (/user not found/i.test(combined)) {
fail('"User not found" appeared with wrong password — migration did not take effect');
} else if (/incorrect password/i.test(combined)) {
ok('Wrong password → "Incorrect password" error ✓ (no "User not found")');
} else {
probe('Error text unclear — capturing snippet', combined.slice(0, 200));
}

// ── Step 5: Correct password → deactivation ──────────────────────────
console.log('\n[Step 5] Correct password → deactivation → redirect');
const pwField2 = page.locator('input[autocomplete="current-password"]').last();
await pwField2.fill(PASSWORD);
await page.locator('button[type="submit"]').last().click();

try { await page.waitForURL(/auth\/signin/, { timeout: 10000 }); } catch { /* may already be there */ }
await page.waitForTimeout(1000);

const afterDeactivate = page.url();
const bodyD = await bodyText(page);
const toastD = await toastText(page);

if (/user not found/i.test(bodyD + toastD)) {
fail('"User not found" with correct password — deactivation broken');
note(`URL: ${afterDeactivate} | body: ${bodyD.slice(0, 300)}`);
} else if (afterDeactivate.includes('deactivated=true')) {
ok('Deactivated → /auth/signin?deactivated=true ✓', afterDeactivate);
} else if (afterDeactivate.includes('/auth/signin')) {
ok('Deactivated → redirected to sign-in page', afterDeactivate);
probe('?deactivated=true param absent from URL — minor UX gap');
} else {
fail('Unexpected location after deactivation', afterDeactivate);
note(`Body: ${bodyD.slice(0, 300)}`);
}

// ── Step 6: Sign-in with deactivated account ─────────────────────────
console.log('\n[Step 6] 🔍 Sign-in with deactivated account — must not reach dashboard');
await page.goto('/auth/signin', { waitUntil: 'domcontentloaded' });
await page.locator('#email').waitFor({ state: 'visible', timeout: 10000 });
await page.locator('#email').fill(EMAIL);
await page.locator('#password').fill(PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForTimeout(4000);

const afterLogin = page.url();
const bodyL = await bodyText(page);
const toastL = await toastText(page);

if (afterLogin.includes('/dashboard')) {
fail('Deactivated account reached dashboard — no guard in place');
} else if (/deactivat/i.test(bodyL + toastL)) {
ok('Sign-in blocked with deactivation message ✓', `url: ${afterLogin}`);
} else {
probe('Deactivated sign-in — no explicit "deactivated" message found');
note(`URL: ${afterLogin} | body: ${bodyL.slice(0, 200)}`);
}

// ── Step 7: Reactivation UI present ─────────────────────────────────
console.log('\n[Step 7] Reactivation UI check');
const pageBodyFull = await bodyText(page);
const toastFull = await toastText(page);
if (/reactivat/i.test(pageBodyFull + toastFull)) {
ok('Reactivation option visible to user after deactivated login attempt');
} else {
probe('No reactivation prompt on sign-in page');
note('Reactivation may require a separate flow — not blocking');
}

// ── Step 8: Session cleared ───────────────────────────────────────────
console.log('\n[Step 8] Session must be cleared after deactivation');
const session = await page.evaluate(async () => {
const r = await fetch('/api/auth/session');
return r.json();
}).catch(() => null);

if (!session || !session.user) {
ok('Session cleared — no active JWT after deactivation ✓');
} else {
warn('Session still active after deactivation');
note(`Session user: ${JSON.stringify(session.user).slice(0, 150)}`);
}

} catch (err) {
fail('Unexpected script error', err.message);
console.error(err);
} finally {
await browser.close();
}

// ── Final report ─────────────────────────────────────────────────────────
console.log('\n' + '─'.repeat(60));
console.log(`Passed: ${passed} Failed: ${failed}`);
if (findings.length) { console.log('\nFindings:'); findings.forEach(f => console.log(f)); }
if (consoleErrors.length) {
console.log('\nBrowser console errors (first 5):');
consoleErrors.slice(0, 5).forEach(e => console.log(' ', e));
}
console.log('─'.repeat(60));
console.log(`Verdict: ${failed === 0 ? 'PASS ✓' : 'FAIL ✗'}`);
process.exit(failed > 0 ? 1 : 0);
}

run();
73 changes: 72 additions & 1 deletion apps/web/app/[locale]/auth/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { AdapterAccountType } from 'next-auth/adapters';
import { db } from '@/lib/db/drizzle';
import { getTenantId } from '@/lib/auth/tenant';

import { signOut } from '@/lib/auth';
import { signOut, unstable_update } from '@/lib/auth';
import { validatedAction, validatedActionWithUser } from '@/lib/auth/middleware';
import { comparePasswords, hashPassword, AuthProviders, AuthErrorCode } from '@/lib/auth/credentials';
import {
Expand All @@ -18,6 +18,8 @@ import {
getVerificationTokenByToken,
getVerificationTokenByEmail,
hardDeleteUser,
deactivateUser,
reactivateUser,
logActivity,
updateUser,
updateUserPassword,
Expand Down Expand Up @@ -537,3 +539,72 @@ export const newPasswordAction = validatedAction(newPasswordSchema, async (data)

return { success: true };
});

// ─── Account Deactivation ─────────────────────────────────────────────────────

const deactivateAccountSchema = z.object({
password: z.string().min(PASSWORD_MIN_LENGTH).max(100),
provider: z.enum(authProviderTypes).default('next-auth')
});

/**
* Temporarily deactivate the signed-in user's account.
* Sets deactivatedAt on the users row and signs the user out.
* The account (credentials, profile, listings) is fully preserved and can be
* restored at any time via reactivateAccount.
*/
export const deactivateAccount = validatedActionWithUser(deactivateAccountSchema, async (data, _, user) => {
const { password } = data;
const dbUser = await getUserByEmail(user.email!).catch(() => null);
if (!dbUser) {
return { error: 'User not found' };
}

if (dbUser.deactivatedAt) {
return { error: 'Account is already deactivated.' };
}

// Mirror the same dual-path password check used by deleteAccount.
const isClientPasswordValid = await verifyClientPassword(user.email!, password);
const isPasswordValid =
isClientPasswordValid || (await comparePasswords(password, dbUser.passwordHash));
if (!isPasswordValid) {
return { error: 'Incorrect password. Account deactivation failed.' };
}

await deactivateUser(dbUser.id);
await logActivity(ActivityType.UPDATE_ACCOUNT, dbUser.id, 'user');

// Sign the user out so the stale (active) session is cleared. The client
// then navigates to /auth/signin where they can log back in and reactivate.
await signOut({ redirect: false });
return { success: true, redirect: '/auth/signin?deactivated=true' };
});

const reactivateAccountSchema = z.object({
provider: z.enum(authProviderTypes).default('next-auth').optional()
});

/**
* Reactivate the signed-in user's previously deactivated account.
* Clears deactivatedAt, restoring full account visibility. Updates the
* JWT in-place so the user stays on the current page with a clean session.
*/
export const reactivateAccount = validatedActionWithUser(reactivateAccountSchema, async (_data, __, user) => {
const dbUser = await getUserByEmail(user.email!).catch(() => null);
if (!dbUser) {
return { error: 'User not found' };
}

if (!dbUser.deactivatedAt) {
return { error: 'Account is not deactivated.' };
}

await reactivateUser(dbUser.id);
await logActivity(ActivityType.UPDATE_ACCOUNT, dbUser.id, 'user');

// Update the JWT in-place so isDeactivated is cleared without signing out.
// The user stays on the danger-zone page and the session is immediately fresh.
await unstable_update({ user: { isDeactivated: false } });
return { success: true };
});
31 changes: 29 additions & 2 deletions apps/web/app/[locale]/client/settings/danger-zone/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,31 @@ import { FiArrowLeft } from 'react-icons/fi';
import { ShieldAlert, Info } from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { getTranslations } from 'next-intl/server';
import { auth } from '@/lib/auth';
import { getUserById } from '@/lib/db/queries';
import { DeleteAccountCard } from '@/components/settings/danger-zone/delete-account-card';
import { DeactivateAccountCard } from '@/components/settings/danger-zone/deactivate-account-card';
import { ReactivateAccountCard } from '@/components/settings/danger-zone/reactivate-account-card';

export default async function DangerZoneSettingsPage() {
const t = await getTranslations('settings.DANGER_ZONE_PAGE');

// Read current deactivation state from DB so the page always reflects the
// live value — the JWT may lag one refresh cycle after reactivation.
const session = await auth();
const userId = session?.user?.id;
let isDeactivated = false;

if (userId) {
try {
const dbUser = await getUserById(userId);
isDeactivated = dbUser?.deactivatedAt != null;
} catch {
// Non-critical — fall back to session value
isDeactivated = session?.user?.isDeactivated ?? false;
}
}

return (
<div className="min-h-screen bg-neutral-50 dark:bg-[#0a0a0a]">
<Container maxWidth="7xl" padding="default" useGlobalWidth>
Expand Down Expand Up @@ -39,7 +59,7 @@ export default async function DangerZoneSettingsPage() {
</p>
</header>

{/* Calm intro banner — neutral surface, just sets expectations */}
{/* Calm intro banner */}
<aside
role="note"
className="flex items-start gap-3 rounded-lg border border-gray-200 dark:border-white/8 bg-white dark:bg-[#111111] px-4 py-3 shadow-sm"
Expand All @@ -53,7 +73,14 @@ export default async function DangerZoneSettingsPage() {
</div>
</aside>

{/* Destructive actions */}
{/* Deactivation section — shown conditionally */}
{isDeactivated ? (
<ReactivateAccountCard />
) : (
<DeactivateAccountCard />
)}

{/* Permanent delete — always shown */}
<DeleteAccountCard />
</div>
</Container>
Expand Down
Loading
Loading