1251 lines
38 KiB
Vue
1251 lines
38 KiB
Vue
<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>
|