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
62 changes: 62 additions & 0 deletions components/dashboard/account/ProfileSettings.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,68 @@
border: 1px solid rgba(239, 68, 68, 0.2);
}

/* GDPR data export — sits above the danger zone with the same row geometry,
but neutral colors: requesting a copy of your data is not a danger action. */
.exportSection {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}

.exportContainer {
border: 2px dashed var(--separator);
border-radius: 8px;
overflow: hidden;
}

.exportBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background-color: var(--primary-text);
color: var(--main-bg);
border: none;
padding: 8px 16px;
border-radius: 40px;
cursor: pointer;
font-weight: 600;
transition: opacity 0.2s;
min-width: 110px;
margin-left: 30px;
}

.exportBtn:hover {
opacity: 0.85;
}

.exportBtn:disabled {
opacity: 0.5;
cursor: default;
}

/* Shown in the delete-account dialog when a live subscription is at stake */
.subscriptionWarning {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 8px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.25);
font-size: 0.85rem;
line-height: 1.5;
color: var(--primary-text);
}

.subscriptionWarningIcon {
flex-shrink: 0;
margin-top: 2px;
color: #eab308;
}

.proStatus {
display: flex;
flex-direction: column;
Expand Down
106 changes: 96 additions & 10 deletions components/dashboard/account/ProfileSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";

import { useEffect, useState } from "react";
import { editUserInfo, deleteUser } from "@src/lib/utils/requests";
import { editUserInfo, deleteUser, requestDataExport } from "@src/lib/utils/requests";
import { signOut } from "next-auth/react";
import { isTauri } from "@tauri-apps/api/core";
import { useRouter } from "next/navigation";
import { ArrowRight, Trash2, Save } from "lucide-react";
import { ArrowRight, Download, Trash2, Save, TriangleAlert } from "lucide-react";
import { useTranslations } from "next-intl";

import form from "./../../utils/Form.module.css";
Expand All @@ -15,6 +15,7 @@ import dangerStyles from "../project/DangerZone.module.css";
import modal from "../../utils/ModalBtn.module.css";
import { ApiResponse } from "@src/lib/utils/api-utils";
import { useUser } from "@src/lib/utils/hooks";
import { useLocale } from "@src/context/LocaleContext";

const PRESET_COLORS = [
"#ef4444", // red
Expand All @@ -32,8 +33,22 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
const router = useRouter();
const t = useTranslations("profile");
const tCommon = useTranslations("common");
const { locale } = useLocale();
const confirmPhrase = t("deleteConfirmPhrase");

// A subscription still renewing is money at stake: deleting the account
// cancels it on the spot, so the dialog has to say so before they confirm.
// Apple bills through the App Store and we cannot cancel it server-side —
// that case needs the opposite warning, or the user assumes billing stops.
const isPro = !!user?.isProUntil && new Date(user.isProUntil) > new Date();
const hasLiveSubscription = isPro && !user?.isSubscriptionCancelled;
const isAppleSubscription = user?.subscriptionProvider === "APPLE";
const proExpiryDate = user?.isProUntil
? new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric" }).format(
new Date(user.isProUntil),
)
: "";

const [username, setUsername] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]);
const [isDirty, setDirty] = useState(false);
Expand All @@ -43,6 +58,11 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmInput, setDeleteConfirmInput] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [exportLoading, setExportLoading] = useState(false);
const [exportMessage, setExportMessage] = useState<{ type: "success" | "error"; text: string } | null>(
null,
);

// Sync state when settings load
useEffect(() => {
Expand All @@ -65,19 +85,47 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
setMessage(null);
};

// GDPR data-access request: the server bundles everything in the background
// and emails a download link, so the only feedback here is "check your inbox".
const handleRequestExport = async () => {
if (exportLoading) return;
setExportLoading(true);
setExportMessage(null);
try {
const res = await requestDataExport();
if (res.ok) {
setExportMessage({ type: "success", text: t("exportRequested") });
} else if (res.status === 409) {
setExportMessage({ type: "error", text: t("exportPending") });
} else {
setExportMessage({ type: "error", text: t("exportFailed") });
}
} catch {
setExportMessage({ type: "error", text: t("exportFailed") });
} finally {
setExportLoading(false);
}
};

const handleDeleteAccount = async () => {
setDeleteLoading(true);
setDeleteError(null);
try {
const res = await deleteUser();
if (res.ok) {
if (isTauri()) {
const { clearDesktopToken } = await import("@src/lib/desktop-auth");
await clearDesktopToken();
} else {
await signOut({ redirect: false });
}
router.replace("/");
if (!res.ok) {
setDeleteError(t("deleteFailed"));
return;
}

if (isTauri()) {
const { clearDesktopToken } = await import("@src/lib/desktop-auth");
await clearDesktopToken();
} else {
await signOut({ redirect: false });
}
router.replace("/");
} catch {
setDeleteError(t("deleteFailed"));
} finally {
setDeleteLoading(false);
}
Expand Down Expand Up @@ -113,6 +161,30 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
if (dangerOpen) {
return (
<>
<div className={styles.exportSection}>
<div className={styles.exportContainer}>
<div className={dangerStyles.dangerItem}>
<div>
<p className={form.label}>{t("exportData")}</p>
<p className={dangerStyles.dangerDescription}>{t("exportDataDesc")}</p>
</div>
<button
className={styles.exportBtn}
onClick={handleRequestExport}
disabled={exportLoading}
>
<Download size={16} />
{exportLoading ? t("exportRequesting") : t("exportBtn")}
</button>
</div>
</div>
{exportMessage && (
<div className={`${styles.message} ${styles[exportMessage.type]}`}>
{exportMessage.text}
</div>
)}
</div>

<div className={dangerStyles.dangerContainer}>
<div className={dangerStyles.dangerItem}>
<div>
Expand All @@ -130,6 +202,16 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
<div className={dangerStyles.modal}>
<h2 className={dangerStyles.modalTitle}>{t("deleteModalTitle")}</h2>
<p className={dangerStyles.modalDescription}>{t("deleteModalDesc")}</p>
{hasLiveSubscription && (
<div className={styles.subscriptionWarning}>
<TriangleAlert size={16} className={styles.subscriptionWarningIcon} />
<span>
{isAppleSubscription
? t("deleteSubscriptionWarningApple", { date: proExpiryDate })
: t("deleteSubscriptionWarning", { date: proExpiryDate })}
</span>
</div>
)}
<label
htmlFor="delete-confirm"
className={dangerStyles.modalDescription}
Expand All @@ -146,6 +228,9 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
onChange={(e) => setDeleteConfirmInput(e.target.value)}
autoComplete="off"
/>
{deleteError && (
<div className={`${styles.message} ${styles.error}`}>{deleteError}</div>
)}
<div className={dangerStyles.modalActions}>
<button
className={`${modal.modalBtn} ${modal.modalBtnDanger}`}
Expand All @@ -160,6 +245,7 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;
onClick={() => {
setShowDeleteDialog(false);
setDeleteConfirmInput("");
setDeleteError(null);
}}
disabled={deleteLoading}
>
Expand Down
16 changes: 16 additions & 0 deletions components/dashboard/project/DangerZone.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@
margin-bottom: 8px;
}

/* The role select is 32px tall inside a collaborator row; in the modal it is a
primary control, so it gets the same footprint as .modalInput. */
.modalSelect {
height: 40px;
font-size: 0.9rem;
margin-top: 8px;
margin-bottom: 8px;
}

.modalError {
font-size: 0.85rem;
color: var(--error);
margin: 0 0 16px 0;
line-height: 1.4;
}

.modalActions {
display: flex;
flex-direction: column;
Expand Down
Loading
Loading