Files
TurboTrades/frontend/src/views/ProfilePage.vue
2026-01-10 04:57:43 +00:00

1251 lines
38 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen bg-surface py-8">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">My Profile</h1>
<p class="text-text-secondary">
Manage your account and security settings
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Sidebar - User Info -->
<div class="space-y-6">
<!-- User Card -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-6 text-center"
>
<img
:src="authStore.avatar"
:alt="authStore.username"
class="w-24 h-24 rounded-full mx-auto mb-4 border-4 border-primary/30"
/>
<h2 class="text-xl font-bold text-white mb-1">
{{ authStore.username }}
</h2>
<p class="text-sm text-text-secondary mb-4">
{{ authStore.steamId }}
</p>
<!-- Staff Badge -->
<div
v-if="authStore.staffLevel > 0"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-primary/20 border border-primary/50 rounded-lg text-primary text-sm font-medium mb-4"
>
<Shield class="w-4 h-4" />
{{ getStaffLevelLabel(authStore.staffLevel) }}
</div>
<!-- Balance -->
<div class="p-4 bg-surface rounded-lg mb-4">
<div class="text-sm text-text-secondary mb-1">Balance</div>
<div class="text-2xl font-bold text-primary">
{{ formatCurrency(authStore.balance) }}
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-2 gap-2">
<router-link to="/deposit" class="btn-primary text-sm py-2">
Deposit
</router-link>
<router-link to="/withdraw" class="btn-secondary text-sm py-2">
Withdraw
</router-link>
</div>
</div>
<!-- Stats Card -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-6"
>
<h3 class="text-white font-semibold mb-4">Statistics</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-text-secondary">Total Purchases</span>
<span class="text-white font-medium">{{
stats.totalPurchases
}}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Total Sales</span>
<span class="text-white font-medium">{{
stats.totalSales
}}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Total Spent</span>
<span class="text-white font-medium">{{
formatCurrency(stats.totalSpent)
}}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Total Earned</span>
<span class="text-white font-medium">{{
formatCurrency(stats.totalEarned)
}}</span>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Account Settings -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-6"
>
<h3 class="text-xl font-bold text-white mb-6">Account Settings</h3>
<!-- Email -->
<div class="mb-6 pb-6 border-b border-surface-lighter">
<div class="flex items-start justify-between mb-3">
<div>
<label class="block text-sm font-medium text-white mb-1"
>Email Address</label
>
<p class="text-sm text-text-secondary">
{{ authStore.email || "No email set" }}
<span
v-if="authStore.emailVerified"
class="ml-2 text-success"
>
<CheckCircle class="w-4 h-4 inline" /> Verified
</span>
<span v-else-if="authStore.email" class="ml-2 text-warning">
<AlertCircle class="w-4 h-4 inline" /> Not verified
</span>
</p>
</div>
<button
@click="showEmailModal = true"
class="btn-secondary text-sm"
>
{{ authStore.email ? "Change" : "Add Email" }}
</button>
</div>
<p class="text-xs text-text-secondary">
Used for account recovery and security notifications
</p>
</div>
<!-- Trade URL -->
<div class="mb-6 pb-6 border-b border-surface-lighter">
<label class="block text-sm font-medium text-white mb-2"
>Steam Trade URL</label
>
<div class="flex gap-2">
<input
v-model="tradeUrl"
type="text"
placeholder="https://steamcommunity.com/tradeoffer/new/?partner=..."
class="input-field flex-1"
:disabled="!isEditingTradeUrl"
/>
<button
v-if="!isEditingTradeUrl"
@click="isEditingTradeUrl = true"
class="btn-secondary"
>
Edit
</button>
<button
v-else
@click="handleUpdateTradeUrl"
:disabled="isLoading"
class="btn-primary"
>
<Loader v-if="isLoading" class="w-4 h-4 animate-spin" />
<span v-else>Save</span>
</button>
</div>
<p class="text-xs text-text-secondary mt-2">
Required to receive items.
<a
href="https://steamcommunity.com/my/tradeoffers/privacy"
target="_blank"
class="text-primary hover:underline"
>
Get your trade URL
</a>
</p>
</div>
<!-- Two-Factor Authentication -->
<div>
<div class="flex items-start justify-between mb-3">
<div>
<h4 class="text-sm font-medium text-white mb-1">
Two-Factor Authentication
</h4>
<p class="text-sm text-text-secondary mb-1">
{{ authStore.twoFactorEnabled ? "Enabled" : "Disabled" }}
<span
v-if="authStore.twoFactorEnabled"
class="ml-2 text-success"
>
<Lock class="w-4 h-4 inline" /> Protected
</span>
</p>
<p class="text-xs text-text-secondary">
Add an extra layer of security to your account
</p>
</div>
<button
v-if="!authStore.twoFactorEnabled"
@click="start2FASetup"
:disabled="loading2FA"
class="btn-primary text-sm"
>
<Loader v-if="loading2FA" class="w-4 h-4 animate-spin" />
<span v-else>Enable 2FA</span>
</button>
<button
v-else
@click="showDisable2FAModal = true"
class="btn-danger text-sm"
>
Disable 2FA
</button>
</div>
</div>
</div>
<!-- Active Sessions -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-6"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<Monitor class="w-6 h-6 text-primary" />
<h3 class="text-xl font-bold text-white">Active Sessions</h3>
</div>
<div class="flex gap-2">
<button
v-if="oldSessionsCount > 0"
@click="revokeOldSessions"
:disabled="loadingSessions"
class="btn-warning text-sm"
>
Revoke Old ({{ oldSessionsCount }})
</button>
<button
v-if="sessions.length > 1"
@click="revokeAllSessions"
:disabled="loadingSessions"
class="btn-danger text-sm"
>
Revoke All Others
</button>
</div>
</div>
<div v-if="loadingSessions" class="text-center py-8">
<Loader class="w-8 h-8 animate-spin mx-auto text-primary" />
</div>
<div
v-else-if="sessions.length === 0"
class="text-center py-8 text-text-secondary"
>
<Monitor class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No active sessions found</p>
</div>
<div v-else class="space-y-4">
<div
v-for="session in sessions"
:key="session.id"
:class="[
'flex items-start gap-4 p-4 bg-surface rounded-lg border transition-colors',
isSessionOld(session.lastActivity)
? 'border-warning/50 hover:border-warning'
: 'border-surface-lighter hover:border-primary/30',
]"
>
<div class="p-3 bg-surface-lighter rounded-lg">
<component
:is="getDeviceIcon(session.device)"
class="w-6 h-6 text-primary"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex-1">
<h3
class="text-white font-semibold flex items-center gap-2 flex-wrap"
>
{{ session.browser || "Unknown" }} on
{{ session.os || "Unknown" }}
<span
v-if="session.isCurrent"
class="text-xs px-2 py-0.5 bg-success/20 text-success rounded-full"
>
Current
</span>
<span
v-if="isSessionOld(session.lastActivity)"
class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full"
>
Old Session
</span>
</h3>
<p class="text-sm text-text-secondary">
{{ session.device || "Desktop" }}
</p>
</div>
<button
@click="openRevokeModal(session)"
class="text-danger hover:text-danger-hover p-1 rounded hover:bg-danger/10 flex-shrink-0"
:title="
session.isCurrent
? 'Revoke current session (will log you out)'
: 'Revoke session'
"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="text-xs text-text-secondary space-y-1">
<p><strong>IP:</strong> {{ session.ip || "Unknown" }}</p>
<p>
<strong>Last Active:</strong>
{{ formatDate(session.lastActivity) }}
</p>
<p class="flex items-center gap-2 flex-wrap">
<strong>Session ID:</strong>
<span
:style="{
backgroundColor: getSessionColor(session.id),
}"
class="px-2 py-0.5 rounded text-[10px] font-mono text-white"
>
{{ getSessionIdShort(session.id) }}
</span>
<span
v-if="!session.isActive"
class="px-2 py-0.5 rounded text-[10px] font-medium bg-gray-600 text-gray-300"
>
INVALIDATED
</span>
</p>
</div>
<div
v-if="isSessionOld(session.lastActivity)"
class="mt-2 pt-2 border-t border-warning/20"
>
<p class="text-xs text-warning flex items-center gap-1">
<AlertCircle class="w-3 h-3" />
This session hasn't been active for a while. If you don't
recognize it, revoke it immediately.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<router-link
to="/inventory"
class="bg-surface-light rounded-lg border border-surface-lighter p-4 hover:border-primary/50 transition-colors"
>
<div class="flex items-center gap-3">
<div
class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center"
>
<Package class="w-6 h-6 text-primary" />
</div>
<div>
<div class="font-semibold text-white">My Inventory</div>
<div class="text-sm text-text-secondary">View your items</div>
</div>
</div>
</router-link>
<router-link
to="/transactions"
class="bg-surface-light rounded-lg border border-surface-lighter p-4 hover:border-primary/50 transition-colors"
>
<div class="flex items-center gap-3">
<div
class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center"
>
<History class="w-6 h-6 text-primary" />
</div>
<div>
<div class="font-semibold text-white">Transactions</div>
<div class="text-sm text-text-secondary">View history</div>
</div>
</div>
</router-link>
</div>
<!-- Danger Zone -->
<div class="bg-surface-light rounded-lg border border-danger/30 p-6">
<h3 class="text-danger font-semibold mb-4">Danger Zone</h3>
<div
class="flex items-center justify-between p-3 bg-surface rounded-lg"
>
<div>
<div class="text-white font-medium text-sm">Log Out</div>
<div class="text-xs text-text-secondary">
Sign out of your account
</div>
</div>
<button @click="authStore.logout" class="btn-danger text-sm">
Log Out
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Email Modal -->
<div
v-if="showEmailModal"
class="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4"
@click.self="showEmailModal = false"
>
<div
class="bg-surface-light rounded-lg border border-surface-lighter max-w-md w-full p-6"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-white">
{{ authStore.email ? "Change Email" : "Add Email" }}
</h3>
<button
@click="showEmailModal = false"
class="text-text-secondary hover:text-white"
>
<X class="w-5 h-5" />
</button>
</div>
<form @submit.prevent="updateEmail">
<div class="mb-4">
<label class="block text-sm font-medium text-text-secondary mb-2"
>Email Address</label
>
<input
v-model="emailForm.email"
type="email"
required
class="input-field"
placeholder="your@email.com"
/>
</div>
<div class="flex gap-3">
<button
type="button"
@click="showEmailModal = false"
class="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
:disabled="loadingEmail"
class="btn-primary flex-1"
>
<Loader v-if="loadingEmail" class="w-4 h-4 animate-spin" />
<span v-else>Save</span>
</button>
</div>
</form>
</div>
</div>
<!-- Session Revoke Confirmation Modal -->
<div
v-if="showRevokeModal"
class="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4"
@click.self="closeRevokeModal"
>
<div
class="bg-surface-light rounded-lg border border-surface-lighter max-w-md w-full p-6"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-white flex items-center gap-2">
<AlertCircle class="w-6 h-6 text-danger" />
{{
sessionToRevoke?.isCurrent
? "Revoke Current Session"
: "Revoke Session"
}}
</h3>
<button
@click="closeRevokeModal"
class="text-text-secondary hover:text-white"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div
v-if="sessionToRevoke?.isCurrent"
class="p-4 bg-danger/10 border border-danger/30 rounded-lg"
>
<p class="text-danger font-medium mb-2"> Warning: Logging Out</p>
<p class="text-sm text-text-secondary">
You are about to revoke your current session. This will
immediately log you out of this device.
</p>
</div>
<div
v-else-if="
sessionToRevoke && isSessionOld(sessionToRevoke.lastActivity)
"
class="p-4 bg-warning/10 border border-warning/30 rounded-lg"
>
<p class="text-warning font-medium mb-2">⚠️ Old Session Detected</p>
<p class="text-sm text-text-secondary">
This session hasn't been active for a while. If you don't
recognize it, it's recommended to revoke it.
</p>
</div>
<div
v-if="sessionToRevoke"
class="p-4 bg-surface rounded-lg border border-surface-lighter"
>
<h4 class="text-white font-medium mb-3">Session Details:</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-text-secondary">Device:</span>
<span class="text-white"
>{{ sessionToRevoke.browser }} on
{{ sessionToRevoke.os }}</span
>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Type:</span>
<span class="text-white">{{ sessionToRevoke.device }}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">IP Address:</span>
<span class="text-white font-mono">{{
sessionToRevoke.ip
}}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Last Active:</span>
<span class="text-white">{{
formatDate(sessionToRevoke.lastActivity)
}}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Session ID:</span>
<span class="flex items-center gap-2">
<span
:style="{
backgroundColor: getSessionColor(sessionToRevoke.id),
}"
class="px-2 py-0.5 rounded text-xs font-mono text-white"
>
{{ getSessionIdShort(sessionToRevoke.id) }}
</span>
</span>
</div>
</div>
</div>
<div class="flex gap-3">
<button
type="button"
@click="closeRevokeModal"
class="btn-secondary flex-1"
>
Cancel
</button>
<button
@click="confirmRevokeSession"
:disabled="revokingSession"
class="btn-danger flex-1"
>
<Loader v-if="revokingSession" class="w-4 h-4 animate-spin" />
<span v-else>{{
sessionToRevoke?.isCurrent
? "Logout & Revoke"
: "Revoke Session"
}}</span>
</button>
</div>
</div>
</div>
</div>
<!-- 2FA Setup Modal -->
<div
v-if="show2FAModal"
class="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4"
@click.self="show2FAModal = false"
>
<div
class="bg-surface-light rounded-lg border border-surface-lighter max-w-md w-full p-6"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-white">
Setup Two-Factor Authentication
</h3>
<button
@click="cancel2FASetup"
class="text-text-secondary hover:text-white"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div v-if="twoFactorData.qrCode" class="text-center">
<p class="text-sm text-text-secondary mb-4">
Scan this QR code with your authenticator app (Google
Authenticator, Authy, etc.)
</p>
<div class="bg-white p-4 rounded-lg inline-block">
<img
:src="twoFactorData.qrCode"
alt="2FA QR Code"
class="w-48 h-48"
/>
</div>
<div
class="mt-4 p-3 bg-surface rounded border border-surface-lighter"
>
<p class="text-xs text-text-secondary mb-1">Manual Entry Code:</p>
<code class="text-sm text-primary font-mono break-all">{{
twoFactorData.secret
}}</code>
</div>
</div>
<div v-if="twoFactorData.qrCode">
<label class="block text-sm font-medium text-text-secondary mb-2">
Enter 6-digit code from your app
</label>
<input
v-model="twoFactorForm.code"
type="text"
maxlength="6"
required
class="input-field text-center text-2xl tracking-widest font-mono"
placeholder="000000"
@input="
twoFactorForm.code = twoFactorForm.code.replace(/[^0-9]/g, '')
"
/>
</div>
<div
v-if="twoFactorData.revocationCode"
class="p-4 bg-warning/10 border border-warning rounded"
>
<div class="flex items-start gap-2">
<AlertCircle class="w-5 h-5 text-warning mt-0.5" />
<div class="text-sm">
<p class="text-warning font-semibold mb-1">
Save your recovery code:
</p>
<code class="text-warning font-mono text-lg">{{
twoFactorData.revocationCode
}}</code>
<p class="text-text-secondary mt-2 text-xs">
You'll need this code to regain access if you lose your
authenticator device.
</p>
</div>
</div>
</div>
<div class="flex gap-3">
<button
type="button"
@click="cancel2FASetup"
class="btn-secondary flex-1"
>
Cancel
</button>
<button
v-if="twoFactorData.qrCode"
@click="verify2FA"
:disabled="loading2FA || twoFactorForm.code.length !== 6"
class="btn-primary flex-1"
>
<Loader v-if="loading2FA" class="w-4 h-4 animate-spin" />
<span v-else>Verify & Enable</span>
</button>
</div>
</div>
</div>
</div>
<!-- Disable 2FA Modal -->
<div
v-if="showDisable2FAModal"
class="fixed inset-0 bg-black/75 flex items-center justify-center z-50 p-4"
@click.self="showDisable2FAModal = false"
>
<div
class="bg-surface-light rounded-lg border border-surface-lighter max-w-md w-full p-6"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-white">
Disable Two-Factor Authentication
</h3>
<button
@click="showDisable2FAModal = false"
class="text-text-secondary hover:text-white"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="mb-4 p-4 bg-danger/10 border border-danger rounded">
<p class="text-sm text-danger">
<AlertCircle class="w-4 h-4 inline mr-1" />
This will make your account less secure. Are you sure?
</p>
</div>
<form @submit.prevent="disable2FA">
<div class="mb-4">
<label class="block text-sm font-medium text-text-secondary mb-2">
Enter your 6-digit 2FA code or recovery code
</label>
<input
v-model="disable2FAForm.code"
type="text"
required
class="input-field text-center font-mono tracking-wider"
placeholder="000000 or recovery code"
/>
</div>
<div class="flex gap-3">
<button
type="button"
@click="showDisable2FAModal = false"
class="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
:disabled="loading2FA"
class="btn-danger flex-1"
>
<Loader v-if="loading2FA" class="w-4 h-4 animate-spin" />
<span v-else>Disable 2FA</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useAuthStore } from "@/stores/auth";
import { useRouter } from "vue-router";
import axios from "@/utils/axios";
import { useToast } from "vue-toastification";
import {
User,
Mail,
Shield,
Wallet,
Package,
History,
CheckCircle,
XCircle,
Loader,
AlertCircle,
Lock,
Monitor,
X,
Smartphone,
Tablet,
Laptop,
} from "lucide-vue-next";
const authStore = useAuthStore();
const router = useRouter();
const toast = useToast();
// State
const isEditingTradeUrl = ref(false);
const isLoading = ref(false);
const tradeUrl = ref("");
// Email
const showEmailModal = ref(false);
const loadingEmail = ref(false);
const emailForm = ref({ email: "" });
// 2FA
const show2FAModal = ref(false);
const showDisable2FAModal = ref(false);
const loading2FA = ref(false);
const twoFactorData = ref({
qrCode: null,
secret: null,
revocationCode: null,
});
const twoFactorForm = ref({ code: "" });
const disable2FAForm = ref({ code: "" });
// Sessions
const sessions = ref([]);
const loadingSessions = ref(false);
const showRevokeModal = ref(false);
const sessionToRevoke = ref(null);
const revokingSession = ref(false);
// Helper function to check if session is old (7+ days inactive)
const isSessionOld = (lastActivity) => {
const now = new Date();
const sessionDate = new Date(lastActivity);
const daysSinceActive = Math.floor((now - sessionDate) / 86400000);
return daysSinceActive > 7;
};
// Computed: Count old sessions (inactive for 7+ days)
const oldSessionsCount = computed(() => {
return sessions.value.filter(
(s) => !s.isCurrent && isSessionOld(s.lastActivity)
).length;
});
// Stats
const stats = ref({
totalPurchases: 0,
totalSales: 0,
totalSpent: 0,
totalEarned: 0,
});
onMounted(async () => {
if (!authStore.isAuthenticated) {
router.push("/");
return;
}
tradeUrl.value = authStore.tradeUrl || "";
// Fetch user stats
const userStats = await authStore.getUserStats();
if (userStats) {
stats.value = userStats;
}
// Fetch sessions
fetchSessions();
});
const handleUpdateTradeUrl = async () => {
if (!tradeUrl.value.trim()) return;
isLoading.value = true;
const success = await authStore.updateTradeUrl(tradeUrl.value);
if (success) {
isEditingTradeUrl.value = false;
}
isLoading.value = false;
};
const updateEmail = async () => {
loadingEmail.value = true;
try {
const success = await authStore.updateEmail(emailForm.value.email);
if (success) {
showEmailModal.value = false;
emailForm.value.email = "";
}
} catch (error) {
console.error("Failed to update email:", error);
} finally {
loadingEmail.value = false;
}
};
const start2FASetup = async () => {
loading2FA.value = true;
try {
const response = await axios.post(
"/api/user/2fa/setup",
{},
{ withCredentials: true }
);
if (response.data.success) {
twoFactorData.value = {
qrCode: response.data.qrCode,
secret: response.data.secret,
revocationCode: response.data.revocationCode,
};
show2FAModal.value = true;
}
} catch (error) {
console.error("Failed to setup 2FA:", error);
toast.error(error.response?.data?.message || "Failed to setup 2FA");
} finally {
loading2FA.value = false;
}
};
const verify2FA = async () => {
// Check if we have 2FA data (QR code, secret) - if not, call setup first
if (!twoFactorData.value.qrCode || !twoFactorData.value.secret) {
toast.error("Please start 2FA setup first");
await start2FASetup();
return;
}
loading2FA.value = true;
try {
const response = await axios.post(
"/api/user/2fa/verify",
{ token: twoFactorForm.value.code },
{ withCredentials: true }
);
if (response.data.success) {
toast.success("Two-factor authentication enabled!");
show2FAModal.value = false;
twoFactorForm.value.code = "";
twoFactorData.value = {
qrCode: null,
secret: null,
revocationCode: null,
};
await authStore.fetchUser();
}
} catch (error) {
console.error("Failed to verify 2FA:", error);
toast.error(error.response?.data?.message || "Invalid 2FA code");
} finally {
loading2FA.value = false;
}
};
const cancel2FASetup = () => {
show2FAModal.value = false;
twoFactorForm.value.code = "";
twoFactorData.value = { qrCode: null, secret: null, revocationCode: null };
};
const disable2FA = async () => {
loading2FA.value = true;
try {
const response = await axios.post(
"/api/user/2fa/disable",
{ password: disable2FAForm.value.code },
{ withCredentials: true }
);
if (response.data.success) {
toast.success("Two-factor authentication disabled");
showDisable2FAModal.value = false;
disable2FAForm.value.code = "";
await authStore.fetchUser();
}
} catch (error) {
console.error("Failed to disable 2FA:", error);
toast.error(error.response?.data?.message || "Invalid code");
} finally {
loading2FA.value = false;
}
};
const fetchSessions = async () => {
loadingSessions.value = true;
try {
const response = await axios.get("/api/user/sessions", {
withCredentials: true,
});
if (response.data.success) {
sessions.value = response.data.sessions;
}
} catch (error) {
console.error("Failed to fetch sessions:", error);
} finally {
loadingSessions.value = false;
}
};
const openRevokeModal = (session) => {
sessionToRevoke.value = session;
showRevokeModal.value = true;
};
const closeRevokeModal = () => {
showRevokeModal.value = false;
sessionToRevoke.value = null;
};
const confirmRevokeSession = async () => {
if (!sessionToRevoke.value) return;
revokingSession.value = true;
try {
const response = await axios.delete(
`/api/user/sessions/${sessionToRevoke.value.id}`,
{
withCredentials: true,
}
);
if (response.data.success) {
toast.success("Session revoked");
if (sessionToRevoke.value.isCurrent) {
// If we revoked our current session, we need to logout
setTimeout(() => {
authStore.logout();
window.location.href = "/";
}, 1000);
} else {
closeRevokeModal();
await fetchSessions();
}
}
} catch (error) {
console.error("Failed to revoke session:", error);
toast.error("Failed to revoke session");
} finally {
revokingSession.value = false;
}
};
const revokeAllSessions = async () => {
if (
!confirm(
"Are you sure you want to revoke all other sessions? This will log out all other devices."
)
) {
return;
}
try {
const response = await axios.post(
"/api/user/sessions/revoke-all",
{},
{ withCredentials: true }
);
if (response.data.success) {
toast.success("All other sessions revoked");
await fetchSessions();
}
} catch (error) {
console.error("Failed to revoke sessions:", error);
toast.error("Failed to revoke sessions");
}
};
// Helper to get last 6 characters of session ID
const getSessionIdShort = (sessionId) => {
if (!sessionId) return "000000";
return sessionId.slice(-6).toUpperCase();
};
// Generate color from session ID (deterministic based on last 6 chars)
const getSessionColor = (sessionId) => {
if (!sessionId) return "#64748b";
const short = sessionId.slice(-6);
let hash = 0;
for (let i = 0; i < short.length; i++) {
hash = short.charCodeAt(i) + ((hash << 5) - hash);
}
// Generate color with good contrast (avoid too light or too dark)
const hue = Math.abs(hash) % 360;
const saturation = 60 + (Math.abs(hash) % 20); // 60-80%
const lightness = 45 + (Math.abs(hash) % 15); // 45-60%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
const revokeOldSessions = async () => {
const oldSessions = sessions.value.filter(
(s) => !s.isCurrent && isSessionOld(s.lastActivity)
);
if (oldSessions.length === 0) {
toast.info("No old sessions to revoke");
return;
}
if (
!confirm(
`Are you sure you want to revoke ${oldSessions.length} old session(s)? These are sessions that haven't been active for more than 7 days.`
)
) {
return;
}
try {
let revokedCount = 0;
for (const session of oldSessions) {
try {
await axios.delete(`/api/user/sessions/${session.id}`, {
withCredentials: true,
});
revokedCount++;
} catch (error) {
console.error(`Failed to revoke session ${session.id}:`, error);
}
}
if (revokedCount > 0) {
toast.success(`${revokedCount} old session(s) revoked`);
await fetchSessions();
} else {
toast.error("Failed to revoke old sessions");
}
} catch (error) {
console.error("Failed to revoke old sessions:", error);
toast.error("Failed to revoke old sessions");
}
};
const getDeviceIcon = (device) => {
if (device === "Mobile") return Smartphone;
if (device === "Tablet") return Tablet;
return Laptop;
};
const formatDate = (date) => {
const d = new Date(date);
const now = new Date();
const diff = now - d;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;
return d.toLocaleDateString();
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const getStaffLevelLabel = (level) => {
const labels = {
0: "User",
1: "Support",
2: "Moderator",
3: "Administrator",
};
return labels[level] || "User";
};
</script>
<style scoped>
.input-field {
width: 100%;
padding: 0.625rem 1rem;
background-color: #151d28;
border-radius: 0.5rem;
border: 1px solid #1f2a3c;
color: white;
transition: border-color 0.2s;
}
.input-field:focus {
outline: none;
border-color: #f58700;
}
.input-field::placeholder {
color: #94a3b8;
}
.input-field:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #f58700 0%, #c46c00 100%);
color: #0f1519;
font-weight: 600;
border-radius: 0.5rem;
transition: opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background-color: #1f2a3c;
color: white;
font-weight: 600;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background-color: #1a2332;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
padding: 0.5rem 1rem;
background-color: #ef4444;
color: white;
font-weight: 600;
border-radius: 0.5rem;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-danger:hover:not(:disabled) {
background-color: #dc2626;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>