diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
index 468d160..46ed41d 100644
--- a/.github/workflows/deploy-web.yml
+++ b/.github/workflows/deploy-web.yml
@@ -22,8 +22,6 @@ jobs:
defaults:
run:
working-directory: apps/web
- env:
- VITE_API_URL: /api/v1
steps:
- uses: actions/checkout@v4
@@ -42,11 +40,6 @@ jobs:
role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- - name: Build frontend
- run: |
- npm ci
- npm run build
-
- name: Terraform init
working-directory: ${{ env.TF_ENV_DIR }}
env:
@@ -60,6 +53,14 @@ jobs:
run: |
echo "bucket=$(terraform output -raw web_bucket_name)" >> $GITHUB_OUTPUT
echo "distribution=$(terraform output -raw cloudfront_distribution_id)" >> $GITHUB_OUTPUT
+ echo "api_url=$(terraform output -raw api_url)" >> $GITHUB_OUTPUT
+
+ - name: Build frontend
+ env:
+ VITE_API_URL: ${{ steps.tf-outputs.outputs.api_url }}/api/v1
+ run: |
+ npm ci
+ npm run build
- name: Sync assets to S3
run: aws s3 sync dist s3://${{ steps.tf-outputs.outputs.bucket }} --delete
diff --git a/apps/api/.env.production.example b/apps/api/.env.production.example
index bfbe935..bd8059c 100644
--- a/apps/api/.env.production.example
+++ b/apps/api/.env.production.example
@@ -23,7 +23,7 @@ AWS_SES_ACCESS_KEY_ID=
AWS_SES_SECRET_ACCESS_KEY=
BILLING_ENABLED=false
CLIENT_URL=https://underflow.example.com
-AUTH_COOKIE_DOMAIN=underflow.example.com
+AUTH_COOKIE_DOMAIN=
AUTH_COOKIE_SAME_SITE=lax
LOG_LEVEL=info
RATE_LIMIT_AUTH_WINDOW_MS=60000
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index c3cd375..2e65be5 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -13,6 +13,8 @@ export const app = express();
const stripeWebhookPath = "/api/v1/subscriptions/webhook/stripe";
const jsonParser = express.json();
+app.set("trust proxy", true);
+
app.use(
cors({
origin: env.CLIENT_URL,
diff --git a/apps/api/src/config/csrf.ts b/apps/api/src/config/csrf.ts
index 921ce7a..784c45a 100644
--- a/apps/api/src/config/csrf.ts
+++ b/apps/api/src/config/csrf.ts
@@ -13,7 +13,7 @@ export const {
getSecret: () => env.CSRF_SECRET,
getSessionIdentifier: (req: Request) => {
const userAgent = req.headers["user-agent"] ?? "unknown-user-agent";
- return `${req.ip}:${userAgent}`;
+ return String(userAgent);
},
cookieName: csrfCookieName,
cookieOptions: {
diff --git a/apps/api/src/controllers/user.controller.ts b/apps/api/src/controllers/user.controller.ts
index c5e99ce..cbe51a4 100644
--- a/apps/api/src/controllers/user.controller.ts
+++ b/apps/api/src/controllers/user.controller.ts
@@ -123,4 +123,16 @@ export const userController = {
res.status(200).json({ message: "Other sessions logged out successfully" });
},
+
+ async requestMyAccountDeletion(req: Request, res: Response): Promise {
+ if (!req.user) {
+ throw new AppError("Unauthorized", 401);
+ }
+
+ await userService.requestAccountDeletion(req.user.id);
+
+ res.status(200).json({
+ message: "Account deletion request submitted successfully",
+ });
+ },
};
diff --git a/apps/api/src/routes/users.routes.ts b/apps/api/src/routes/users.routes.ts
index b15a983..a21e799 100644
--- a/apps/api/src/routes/users.routes.ts
+++ b/apps/api/src/routes/users.routes.ts
@@ -48,3 +48,9 @@ usersRouter.post(
authMiddleware,
asyncHandler(userController.logoutOtherSessions),
);
+usersRouter.post(
+ "/me/request-account-deletion",
+ mutationRateLimit,
+ authMiddleware,
+ asyncHandler(userController.requestMyAccountDeletion),
+);
diff --git a/apps/api/src/services/user.service.ts b/apps/api/src/services/user.service.ts
index 75e66b9..7240e9f 100644
--- a/apps/api/src/services/user.service.ts
+++ b/apps/api/src/services/user.service.ts
@@ -9,6 +9,7 @@ import type {
UserSession,
User,
} from "../types/auth.types.js";
+import { logger } from "../lib/logger.js";
import { AppError } from "../utils/app-error.js";
import { hashPassword, verifyPassword } from "../utils/password.js";
@@ -172,4 +173,17 @@ export const userService = {
await refreshTokenRepository.revokeByUserIdExcept(userId, currentSessionId);
},
+
+ async requestAccountDeletion(userId: string): Promise {
+ const user = await userRepository.findById(userId);
+
+ if (!user) {
+ throw new AppError("User not found", 404);
+ }
+
+ logger.warn("Account deletion requested", {
+ userId: user.id,
+ email: user.email,
+ });
+ },
};
diff --git a/apps/api/src/test/app.test.ts b/apps/api/src/test/app.test.ts
index a17b074..1807cfe 100644
--- a/apps/api/src/test/app.test.ts
+++ b/apps/api/src/test/app.test.ts
@@ -694,6 +694,35 @@ test("POST /api/v1/users/me/sessions/logout-others revokes all other sessions",
}
});
+test("POST /api/v1/users/me/request-account-deletion records a deletion request", async () => {
+ const { app, userService, signAccessToken } = await loadModules();
+ const originalRequestAccountDeletion = userService.requestAccountDeletion;
+
+ let capturedUserId: string | null = null;
+ userService.requestAccountDeletion = async (userId) => {
+ capturedUserId = userId;
+ };
+
+ try {
+ const user = buildUser();
+ const accessToken = signAccessToken(buildAuthenticatedClaims(user));
+
+ const response = await request(app)
+ .post("/api/v1/users/me/request-account-deletion")
+ .set("Authorization", `Bearer ${accessToken}`)
+ .send({});
+
+ assert.equal(response.status, 200);
+ assert.equal(capturedUserId, "user-123");
+ assert.equal(
+ response.body.message,
+ "Account deletion request submitted successfully",
+ );
+ } finally {
+ userService.requestAccountDeletion = originalRequestAccountDeletion;
+ }
+});
+
test("GET /api/v1/notifications returns the notifications feed", async () => {
const { app, notificationService, signAccessToken } = await loadModules();
const originalGetFeedForUser = notificationService.getFeedForUser;
diff --git a/apps/web/.env.production.example b/apps/web/.env.production.example
index 43322c4..62fdf35 100644
--- a/apps/web/.env.production.example
+++ b/apps/web/.env.production.example
@@ -1,3 +1,3 @@
-VITE_API_URL=/api/v1
+VITE_API_URL=https://api.underflow.example.com/api/v1
VITE_APP_NAME=Underflow
VITE_DEFAULT_THEME=system
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index ad0b371..6a2682c 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -29,7 +29,21 @@ export const AUTH_SESSION_EXPIRED_EVENT = "underflow:auth-session-expired";
const parseResponse = async (response: Response): Promise => {
const text = await response.text();
- const data = text ? (JSON.parse(text) as unknown) : {};
+ let data: unknown = {};
+
+ if (text) {
+ try {
+ data = JSON.parse(text) as unknown;
+ } catch {
+ throw new ApiError(
+ response.status,
+ response.ok ? "Received a non-JSON response" : "Unexpected non-JSON error response",
+ {
+ responseTextPreview: text.slice(0, 200),
+ },
+ );
+ }
+ }
if (!response.ok) {
const payload =
diff --git a/apps/web/src/lib/api/users.ts b/apps/web/src/lib/api/users.ts
index 91b8c19..1b31a8e 100644
--- a/apps/web/src/lib/api/users.ts
+++ b/apps/web/src/lib/api/users.ts
@@ -53,4 +53,10 @@ export const usersApi = {
body: {},
requireAuth: true,
}),
+ requestAccountDeletion: () =>
+ apiRequest<{ message: string }>("/users/me/request-account-deletion", {
+ method: "POST",
+ body: {},
+ requireAuth: true,
+ }),
};
diff --git a/apps/web/src/pages/app/ProfileSettingsPage.tsx b/apps/web/src/pages/app/ProfileSettingsPage.tsx
index c032d72..bc889aa 100644
--- a/apps/web/src/pages/app/ProfileSettingsPage.tsx
+++ b/apps/web/src/pages/app/ProfileSettingsPage.tsx
@@ -65,6 +65,7 @@ export const ProfileSettingsPage = (): JSX.Element => {
const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isSavingPreferences, setIsSavingPreferences] = useState(false);
const [isLoggingOutOthers, setIsLoggingOutOthers] = useState(false);
+ const [isRequestingDeletion, setIsRequestingDeletion] = useState(false);
const preferencesQuery = useAsyncData(
async () => {
@@ -200,6 +201,37 @@ export const ProfileSettingsPage = (): JSX.Element => {
}
};
+ const handleRequestAccountDeletion = async () => {
+ setError(null);
+
+ const confirmed = window.confirm(
+ "Send an account deletion request? This does not delete your account immediately.",
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ setIsRequestingDeletion(true);
+
+ try {
+ await usersApi.requestAccountDeletion();
+ showToast({
+ title: "Deletion request submitted",
+ description: "Our team can now review your account deletion request.",
+ tone: "success",
+ });
+ } catch (submitError) {
+ setError(
+ submitError instanceof Error
+ ? submitError.message
+ : "Unable to submit account deletion request",
+ );
+ } finally {
+ setIsRequestingDeletion(false);
+ }
+ };
+
const updatePreference = (key: keyof EmailPreferencesState, value: boolean) => {
setEmailPreferences((current) => ({ ...current, [key]: value }));
};
@@ -472,8 +504,12 @@ export const ProfileSettingsPage = (): JSX.Element => {
resource data.
-